feat: dashboard needs-attention counts and lifecycle activation emails - #270
feat: dashboard needs-attention counts and lifecycle activation emails#270JoachimLK wants to merge 9 commits into
Conversation
Replace the "To Review" tile's `new`-stage count with per-user read receipts, and add the two things the dashboard was silent about. * "To Review" now counts applicants the signed-in user has never opened, matching the per-job badges on /jobs. The `new` stage stayed high long after every application in it had been read, so the tile could not be worked down to zero. Clicking through opens the list filtered to the same set (`?viewed=unviewed`), which the applications API and a new Viewed filter on the list now support, so the number and the rows behind it cannot disagree. * Unanswered candidate replies: threads whose newest message is inbound. Derived from message order rather than `candidateConversation.unread Count`, because nothing in the product clears that counter — a badge built on it could only ever grow. "Newest message is inbound" clears itself the moment anything goes out on the thread. * Application detail tabs are addressable (`?tab=inbox`), validated against a shared list so a hand-edited value cannot land the component in a state with no content. The reply card links straight into the thread rather than the overview. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first step of the funnel is "signed up, never opened a role", which is an absence — there is no moment at which it happens, so it cannot be enqueued the way `notificationOutbox` enqueues workspace activity. Instead we emit the event that starts the clock (`org.created`) and the one that stops it (`job.posted`), and a Wait-for-Event step in Resend decides whether anyone is mailed. The copy and the timing live in the Resend dashboard, editable without a deploy — at this stage that is the point, since we do not yet know what the email should say. Off by default. Unlike NOTIFICATIONS_ENABLED, LIFECYCLE_EMAILS_ENABLED fails closed: a recruiter notification is mail the operator's own users asked for, but a lifecycle event ships a user's address to *our* Resend account, which is the wrong default for anyone self-hosting. Emits are fire-and-forget and swallow their own failures — neither creating a workspace nor opening a role may fail, or even slow down, because a marketing automation was unreachable. A dropped event costs one nudge email; a durable queue for that would be a much larger change than the funnel currently justifies. Failures log under `category: lifecycle_event`. Demo workspaces, the demo account, and reserved (`@example.com`) addresses are skipped — the shared demo org would otherwise enter the funnel as a signup that never posted a role, and sample applicants can only ever hard bounce against the sending domain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🚅 Deployed to the reqcore-pr-270 environment in applirank
|
|
Warning Review limit reached
Next review available in: 1 minute You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (16)
📝 WalkthroughWalkthroughThe change adds per-user viewed and unviewed application filtering, dashboard metrics for unviewed applications, an unanswered-replies queue, Inbox deep links, optional Resend Automations events, job compensation fields, and feed delivery tracking. ChangesDashboard application workflows
Lifecycle email events
Job compensation and feed distribution
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant DashboardPage
participant DashboardStats
participant ApplicationsEndpoint
participant ApplicationViews
DashboardPage->>DashboardStats: request dashboard counts and replies
DashboardStats->>ApplicationViews: count unviewed applications
DashboardPage->>ApplicationsEndpoint: request viewed or unviewed applications
ApplicationsEndpoint->>ApplicationViews: apply read-receipt condition
ApplicationViews-->>DashboardPage: filtered applications and counts
sequenceDiagram
participant JobWizard
participant JobsAPI
participant JobsFeed
participant FeedFetch
participant JobPromotePanel
JobWizard->>JobsAPI: submit compensation fields
JobsAPI-->>JobWizard: create job
JobsFeed->>FeedFetch: record board fetch metadata
JobsFeed->>JobsFeed: mark included jobs
JobPromotePanel->>JobsAPI: request delivery status
JobsAPI-->>JobPromotePanel: return board delivery states
sequenceDiagram
participant OrganizationCreation
participant JobCreation
participant LifecycleEvents
participant ResendAutomations
OrganizationCreation->>LifecycleEvents: emit org.created
JobCreation->>LifecycleEvents: emit job.posted
LifecycleEvents->>ResendAutomations: submit eligible event
ResendAutomations-->>LifecycleEvents: return delivery result
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/components/ApplicationsList.vue (1)
410-417: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winInclude
viewedwhen comparing saved-view settings.A saved view that differs only by
viewedappears clean. The UI can then hide the unsaved change and prevent the user from updating the saved view.Proposed fix
function settingsEqual(a: ApplicationsViewSettings, b: ApplicationsViewSettings) { return a.status === b.status + && a.viewed === b.viewed && a.jobId === b.jobId🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/components/ApplicationsList.vue` around lines 410 - 417, Update settingsEqual to compare the viewed property in addition to the existing status, jobId, sort, propertyFilters, and visibleColumns fields, so changes to viewed mark the saved view as modified.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/components/ApplicationDetail.vue`:
- Around line 52-53: Update ApplicationDetail’s reactive state so detailTab
stays synchronized with changes to props.initialTab, including same-application
route query navigation. Add a watcher for props.initialTab alongside the
existing applicationId watcher and assign the new value to detailTab.
In `@app/components/ApplicationsList.vue`:
- Around line 159-176: Replace the independent filter watchers around
activeStatus and activeViewed with one watcher that observes both state values
and route changes, then updates query.status and query.viewed together,
including removing either key when its value is undefined. Ensure the combined
router.replace uses the latest route query so changing one filter does not
overwrite the other.
---
Outside diff comments:
In `@app/components/ApplicationsList.vue`:
- Around line 410-417: Update settingsEqual to compare the viewed property in
addition to the existing status, jobId, sort, propertyFilters, and
visibleColumns fields, so changes to viewed mark the saved view as modified.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: f37f712f-a730-49e3-9bbb-caf310e8b2b8
📒 Files selected for processing (21)
.env.exampleCHANGELOG.mdapp/components/ApplicationDetail.vueapp/components/ApplicationsList.vueapp/composables/useApplications.tsapp/composables/useDashboard.tsapp/pages/dashboard/applications/[id].vueapp/pages/dashboard/index.vueserver/api/applications/index.get.tsserver/api/dashboard/stats.get.tsserver/api/jobs/index.post.tsserver/utils/applicationViews.tsserver/utils/auth.tsserver/utils/env.tsserver/utils/lifecycle/events.tsserver/utils/schemas/application.tsserver/utils/unansweredReplies.tsshared/application-detail-tabs.tstests/unit/application-detail-tabs.test.tstests/unit/application-views.test.tstests/unit/lifecycle-events.test.ts
| type DetailTab = ApplicationDetailTab | ||
| const detailTab = ref<DetailTab>(props.initialTab) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
ast-grep outline 'app/pages/dashboard/applications/[id].vue' --items all
rg -n -C 5 'ApplicationDetail|initialTab|parseApplicationDetailTab|route\.query\.tab' \
'app/pages/dashboard/applications/[id].vue'Repository: reqcore-inc/reqcore
Length of output: 1488
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
fd -i 'ApplicationDetail.vue' .
fd -i 'application-detail-tabs' .
printf '\n--- component outline ---\n'
ast-grep outline "$(fd -i -t f 'ApplicationDetail.vue' . | head -n 1)" --items all
printf '\n--- relevant component references ---\n'
component="$(fd -i -t f 'ApplicationDetail.vue' . | head -n 1)"
rg -n -C 8 'initialTab|detailTab|applicationId|watch\(' "$component"
printf '\n--- tab parser and usages ---\n'
tabfile="$(fd -i -t f 'application-detail-tabs' . | head -n 1)"
cat -n "$tabfile"
rg -n -C 5 'initial-tab|initialTab|route\.query\.tab|parseApplicationDetailTab' app shared 2>/dev/null || trueRepository: reqcore-inc/reqcore
Length of output: 32309
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
import re
page = Path("app/pages/dashboard/applications/[id].vue").read_text()
component = Path("app/components/ApplicationDetail.vue").read_text()
assert "const initialTab = computed(() => parseApplicationDetailTab(route.query.tab))" in page
assert ':initial-tab="initialTab"' in page
assert "const detailTab = ref<DetailTab>(props.initialTab)" in component
watchers = re.findall(
r"watch\(\s*(?:\(\)\s*=>\s*)?([^,\n]+),",
component,
)
assert any("props.applicationId" in watcher for watcher in watchers)
assert not any("props.initialTab" in watcher for watcher in watchers)
print("page initialTab is derived from route.query.tab and passed as a prop")
print("detailTab is initialized from props.initialTab")
print("the existing reset watcher observes props.applicationId")
print("no watcher observes props.initialTab")
print("same-application query changes can therefore leave detailTab unchanged")
PYRepository: reqcore-inc/reqcore
Length of output: 437
Synchronize detailTab with initialTab.
When same-application navigation changes route.query.tab, initialTab updates but detailTab does not. The watcher at Lines 642-646 observes only props.applicationId. Add a watcher for props.initialTab.
Proposed fix
const detailTab = ref<DetailTab>(props.initialTab)
+
+watch(() => props.initialTab, (tab) => {
+ detailTab.value = tab
+})📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| type DetailTab = ApplicationDetailTab | |
| const detailTab = ref<DetailTab>(props.initialTab) | |
| type DetailTab = ApplicationDetailTab | |
| const detailTab = ref<DetailTab>(props.initialTab) | |
| watch(() => props.initialTab, (tab) => { | |
| detailTab.value = tab | |
| }) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/components/ApplicationDetail.vue` around lines 52 - 53, Update
ApplicationDetail’s reactive state so detailTab stays synchronized with changes
to props.initialTab, including same-application route query navigation. Add a
watcher for props.initialTab alongside the existing applicationId watcher and
assign the new value to detailTab.
| const initialViewed = VIEWED_OPTIONS.includes(route.query.viewed as any) | ||
| ? (route.query.viewed as ViewedFilter) | ||
| : undefined | ||
| const activeViewed = useState<ViewedFilter | undefined>(`app-filter-viewed${scopeSuffix}`, () => initialViewed) | ||
| if (initialViewed !== undefined) { | ||
| activeViewed.value = initialViewed | ||
| } | ||
|
|
||
| watch(activeViewed, (next) => { | ||
| const query = { ...route.query } | ||
| if (next) { | ||
| query.viewed = next | ||
| } | ||
| else { | ||
| delete query.viewed | ||
| } | ||
| router.replace({ query }) | ||
| }) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
rg -n -C 5 'activeStatus|activeViewed|route\.query\.(status|viewed)|router\.replace|clearAllFilters|applySettings' \
app/components/ApplicationsList.vueRepository: reqcore-inc/reqcore
Length of output: 9982
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- component imports and filter setup ---'
sed -n '1,190p' app/components/ApplicationsList.vue
printf '%s\n' '--- settings and view application logic ---'
sed -n '350,445p' app/components/ApplicationsList.vue
printf '%s\n' '--- route/filter references in the component ---'
rg -n -C 4 'useRoute|useRouter|route\.query|watch\(|useState|clearAllFilters|applySettings' app/components/ApplicationsList.vueRepository: reqcore-inc/reqcore
Length of output: 15095
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- dependency versions ---'
rg -n '"(vue|vue-router|nuxt)"' package.json pnpm-lock.yaml yarn.lock package-lock.json 2>/dev/null || true
printf '%s\n' '--- deterministic watcher/query model ---'
python3 - <<'PY'
from copy import deepcopy
def status_write(route_query, status):
query = deepcopy(route_query)
if status:
query["status"] = status
else:
query.pop("status", None)
return query
def viewed_write(route_query, viewed):
query = deepcopy(route_query)
if viewed:
query["viewed"] = viewed
else:
query.pop("viewed", None)
return query
cases = [
("both added", {}, "new", "unviewed"),
("both changed", {"status": "old", "viewed": "viewed"}, "new", "unviewed"),
("both cleared", {"status": "new", "viewed": "unviewed"}, None, None),
]
for name, route, status, viewed in cases:
first = status_write(route, status)
second = viewed_write(route, viewed)
print(name)
print(" status watcher submits:", first)
print(" viewed watcher submits:", second)
print(" last submitted query:", second)
PYRepository: reqcore-inc/reqcore
Length of output: 2274
🌐 Web query:
Vue Router 4 router.replace returns Promise navigation asynchronous current route update documentation
💡 Result:
In Vue Router 4, the router.replace method (like router.push) returns a Promise that allows you to wait until the navigation is fully finished [1][2]. Key technical details regarding this Promise: 1. Asynchronous Resolution: The Promise resolves once the navigation successfully completes or fails [3]. Navigation includes the processing of all asynchronous navigation guards, such as those that resolve async route components or perform data fetching [4]. 2. Success vs. Failure: To handle navigation failures (e.g., cancelling a navigation via next(false)), the Promise resolves to either undefined (on success) or a NavigationFailure object (if the navigation was aborted or cancelled) [3]. 3. Rejection: The Promise only rejects if an unhandled error occurs during the navigation process (e.g., an error inside a navigation guard) [3]. In such cases, the error will also be triggered through the router.onError hook [3]. Because the Promise resolves rather than rejects when a navigation is simply cancelled, you can await the call safely without triggering "Uncaught (in promise)" errors for expected navigation failures [3]. By checking the returned value or catching errors specifically, you can differentiate between successful, cancelled, and failed navigations [3].
Citations:
- 1: https://github.com/vuejs/router/blob/main/packages/docs/guide/essentials/navigation.md
- 2: https://typeerror.org/docs/vue_router~4/guide/essentials/navigation
- 3: https://github.com/vuejs/rfcs/blob/master/active-rfcs/0033-router-navigation-failures.md
- 4: https://router.vuejs.org/guide/advanced/navigation-guards.html
Synchronize status and viewed filters in one route-query update.
Watch route changes and update both activeStatus and activeViewed, including undefined. Replace the two independent router.replace watchers with one watcher that writes both query parameters. Separate updates can submit stale route.query snapshots and discard the other filter change.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/components/ApplicationsList.vue` around lines 159 - 176, Replace the
independent filter watchers around activeStatus and activeViewed with one
watcher that observes both state values and route changes, then updates
query.status and query.viewed together, including removing either key when its
value is undefined. Ensure the combined router.replace uses the latest route
query so changing one filter does not overwrite the other.
CI rejected the lockfile with "Missing: oxc-parser@0.144.0 from lock file" — an optional peer of unctx@3.0.0 (`oxc-parser >=0.140.0`) that the lock pins at 0.128.0. The lock is not wrong so much as generated under different rules: with `legacy-peer-deps=true`, npm skips peer resolution entirely and never records that peer, and that flag has been set in a personal ~/.npmrc rather than in the repo. Every local `npm install` therefore produced a lock that a runner with no user npmrc refuses. Moving the setting into the repo makes resolution a property of the project instead of of whoever last ran npm install, so CI, Docker builds and every contributor resolve the same tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The image build ran `npm ci` against a lockfile generated under `legacy-peer-deps`, but only `package*.json` was copied in — so the resolution mode never reached it and the build failed on an optional peer of unctx (`Missing: oxc-parser@0.144.0 from lock file`), the same way CI did before the repo npmrc landed. Reproduced against node:22.22-alpine (npm 10.9.8): fails without the file, succeeds with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Introduces salary input fields to the job builder, including support for ranges, currency codes, and payment units. Adds validation to ensure salary details are complete and logically sound before publishing.
- Add `feed_fetch` table to log feed requests per board - Add `last_included_in_feed_at` to `job` table - Implement `boardDeliveryState` logic to verify job presence in pulls - Add UI components for per-board delivery status - Add cleanup task for `feed_fetch` logs
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@server/database/schema/app.ts`:
- Around line 154-166: Replace the global lastIncludedInFeedAt field with a
per-fetch job receipt relation: in server/database/schema/app.ts:154-166 remove
the global evidence, and in server/database/schema/app.ts:858-893 add a receipt
table linking feed_fetch.id to job.id with board-fetch and job lookup indexes.
In server/database/migrations/0067_lame_rachel_grey.sql:1-13 create the table,
foreign keys, and indexes; in server/routes/jobs.xml.ts:153-162 retain included
job IDs and in server/routes/jobs.xml.ts:214-230 insert the fetch and receipts
transactionally. Update boardDeliveryState in server/utils/jobFeed.ts:253-271
and the promotion queries in server/api/jobs/[id]/promote.get.ts:120-147 to use
the latest board fetch receipt, marking delivery only when that fetch includes
the job.
In `@server/routes/jobs.xml.ts`:
- Around line 217-218: Remove the raw IP assignment from the public feed request
logging object in the route handling this code, including the getRequestIP call.
Retain the verified board identifier and fetch-time fields needed for delivery
status, and do not add derived IP diagnostics unless an existing documented
retention mechanism is available.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 9163e245-e200-472c-bf8a-ab0af69af8a0
📒 Files selected for processing (17)
.npmrcDockerfileapp/components/ApplicationBuilderPreview.vueapp/components/JobPromotePanel.vueapp/composables/useJobs.tsapp/pages/dashboard/jobs/new.vuee2e/critical-flows/job-creation.spec.tsserver/api/jobs/[id]/promote.get.tsserver/database/migrations/0067_lame_rachel_grey.sqlserver/database/migrations/meta/0067_snapshot.jsonserver/database/migrations/meta/_journal.jsonserver/database/schema/app.tsserver/routes/jobs.xml.tsserver/tasks/retention-cleanup.tsserver/utils/jobFeed.tsshared/sample-job.tstests/unit/feed-delivery.test.ts
| /** | ||
| * Last time this job was actually emitted into a served `/jobs.xml` response. | ||
| * | ||
| * Syndication is pull-based: nothing is ever posted, aggregators fetch the | ||
| * feed on their own schedule. So "this role is on the boards" is not | ||
| * something publishing can assert — the only evidence that exists is that a | ||
| * board asked for the feed and this job was in what we handed back. | ||
| * | ||
| * Stamped with the same clock as the `feed_fetch` row written for that | ||
| * request, which is what lets `boardDeliveryState` compare the two: a job | ||
| * whose timestamp is older than a board's last fetch was *not* in that pull. | ||
| */ | ||
| lastIncludedInFeedAt: timestamp('last_included_in_feed_at'), |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Persist per-board job receipts instead of one global inclusion timestamp.
A later fetch from Board B updates job.lastIncludedInFeedAt. The classifier then marks Board A as delivered when Board A's latest pull excluded the job. This occurs when eligibility changes between the two pulls.
server/database/schema/app.ts#L154-L166: replace the globallastIncludedInFeedAtevidence with a per-fetch job receipt relation.server/database/schema/app.ts#L858-L893: add a receipt table that linksfeed_fetch.idtojob.id, with indexes for board-fetch and job lookups.server/database/migrations/0067_lame_rachel_grey.sql#L1-L13: create the receipt table, foreign keys, and indexes in this migration.server/routes/jobs.xml.ts#L153-L162: retain included IDs for receipt insertion.server/routes/jobs.xml.ts#L214-L230: insert the fetch row and its job receipts in one transaction.server/utils/jobFeed.ts#L253-L271: classify a board as delivered only when that board's latest fetch has a receipt for the job.server/api/jobs/[id]/promote.get.ts#L120-L147: query the latest fetch and receipt for each board.
📍 Affects 5 files
server/database/schema/app.ts#L154-L166(this comment)server/database/schema/app.ts#L858-L893server/database/migrations/0067_lame_rachel_grey.sql#L1-L13server/routes/jobs.xml.ts#L153-L162server/routes/jobs.xml.ts#L214-L230server/utils/jobFeed.ts#L253-L271server/api/jobs/[id]/promote.get.ts#L120-L147
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@server/database/schema/app.ts` around lines 154 - 166, Replace the global
lastIncludedInFeedAt field with a per-fetch job receipt relation: in
server/database/schema/app.ts:154-166 remove the global evidence, and in
server/database/schema/app.ts:858-893 add a receipt table linking feed_fetch.id
to job.id with board-fetch and job lookup indexes. In
server/database/migrations/0067_lame_rachel_grey.sql:1-13 create the table,
foreign keys, and indexes; in server/routes/jobs.xml.ts:153-162 retain included
job IDs and in server/routes/jobs.xml.ts:214-230 insert the fetch and receipts
transactionally. Update boardDeliveryState in server/utils/jobFeed.ts:253-271
and the promotion queries in server/api/jobs/[id]/promote.get.ts:120-147 to use
the latest board fetch receipt, marking delivery only when that fetch includes
the job.
| userAgent: getRequestHeader(event, 'user-agent')?.slice(0, 512) ?? null, | ||
| ipAddress: getRequestIP(event, { xForwardedFor: true }) ?? null, |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Do not retain raw requester IP addresses for public feed requests.
This route logs untagged browser requests. ipAddress can therefore contain personal data for users who are not job-board crawlers. Delivery status only needs a verified board identifier and fetch time.
Remove raw IP storage. If diagnostics are required, store a minimized derived value with documented retention.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@server/routes/jobs.xml.ts` around lines 217 - 218, Remove the raw IP
assignment from the public feed request logging object in the route handling
this code, including the getRequestIP call. Retain the verified board identifier
and fetch-time fields needed for delivery status, and do not add derived IP
diagnostics unless an existing documented retention mechanism is available.
Introduce `tierUsesFreeAllowances` to normalize AI usage metering across the `free` and `grandfathered` tiers. This enables `grandfathered` workspaces to utilize platform AI with a bounded cost, correcting a long-standing issue where many legacy orgs lacked AI access due to mandatory BYOK requirements.
Introduces `GRANDFATHERED_CONVERSATION_CAP_START` to allow grandfathered orgs to adopt the Free tier's candidate conversation limit without being retroactively blocked for legacy activity. Updates the allowance logic to filter conversation counts based on this timestamp. Additionally, removes the hard exclusion of grandfathered orgs from platform AI usage. These orgs now resolve to the platform provider by default, with their usage metered against the Free tier allowance rather than being restricted to BYOK.
Two unrelated bodies of work that had accumulated uncommitted, kept as separate commits.
1. Dashboard: surface what actually needs attention (
17f6068)"To Review" now counts unopened applicants, not the
newstage. The old count stayed high long after every application in it had been read, so the tile could never be worked down to zero — and moving a candidate along the pipeline is a separate decision from having looked at them. It now uses the per-user read receipts introduced in #266, matching the per-job badges on/jobs. Clicking through opens the list filtered to the same set (?viewed=unviewed), backed by a newviewedfilter on the applications API and a matching control on the list, so the number and the rows behind it can't disagree.Unanswered candidate replies — a new dashboard card for threads whose newest message is inbound. Derived from message order rather than
candidateConversation.unreadCount: nothing in the product ever clears that counter, so a badge built on it could only grow. "Newest message is inbound" self-clears the moment anything goes out on the thread.Application detail tabs are addressable (
?tab=inbox), validated against a shared list so a hand-edited value can't land the component in a state with no content. The reply card links straight into the thread.2. Lifecycle activation emails (
2c35b1b)Emits
org.createdandjob.postedto Resend Automations, which owns the delay, the branching, and the copy for the "signed up, never opened a role" nudge.This is deliberately not built on
notificationOutbox. That engine reports workspace activity — something happened, come look. This one chases an absence, and an absence has no moment to enqueue at; the waiting lives in a Resend Wait-for-Event step instead. It also means the copy is editable without a deploy, which matters while we still don't know what the email should say.LIFECYCLE_EMAILS_ENABLEDfails closed, unlikeNOTIFICATIONS_ENABLED— enabling it ships user email addresses to the Resend account behindRESEND_API_KEY, which is only ever right on hosted.category: lifecycle_event.@example.comaddresses (sample applicants can only hard bounce, and bounces score against the sending domain).isTestrides in thejob.postedpayload rather than gating the emit — whether a test role counts as activation is a copy decision, so it belongs in the automation's condition.Not done in this PR
The automation itself doesn't exist in Resend yet, so events currently go nowhere. Nothing sends until both the automation is built and the env var is set.
Testing
928 unit tests pass;
vue-tscclean. New:tests/unit/lifecycle-events.test.ts(8 cases),tests/unit/application-detail-tabs.test.ts; extendedtests/unit/application-views.test.ts.Known gap:
server/utils/unansweredReplies.tshas no test. TheselectDistinctOnordering in it is the kind of thing that breaks quietly.🤖 Generated with Claude Code
Summary by CodeRabbit