@thejob/schema 2.1.2 → 2.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ import { number, object, string } from "yup";
2
+ import { JobAlertFrequency, SupportedJobAlertFrequencies } from "../user/user.constant.js";
3
+ import { SavedSearchStatus, SupportedSavedSearchStatuses } from "./saved-search.constant.js";
4
+
5
+ /**
6
+ * A search a user asked to be kept, and optionally to be alerted about.
7
+ *
8
+ * Two jobs in one document, deliberately: the query the user built, and the
9
+ * cadence at which they want it re-run for them. Splitting them would mean a
10
+ * user with three saved searches and one alert preference has no way to say
11
+ * which search the alert is for.
12
+ *
13
+ * The stored query mirrors what `GET /jobs/search-v3` accepts — a free-text
14
+ * `search` plus a `filter` map — so replaying a saved search is running the same
15
+ * search the user originally ran, not a second, subtly different implementation
16
+ * that drifts from it.
17
+ */
18
+ export const SavedSearchSchema = object({
19
+ /** Public identifier. Server-assigned. */
20
+ shortId: string().trim().label("Short Id"),
21
+
22
+ /**
23
+ * Owner's Better Auth account id. Server-assigned from the verified token,
24
+ * never accepted from a request body.
25
+ */
26
+ ownerUserId: string().trim().label("Owner"),
27
+
28
+ /** What the user calls this search, e.g. "Remote React roles". */
29
+ label: string().trim().max(120).required().label("Label"),
30
+
31
+ /**
32
+ * The free-text query. Optional because a search can be filters-only ("all
33
+ * remote jobs in Sweden"), which is a legitimate alert to want.
34
+ */
35
+ search: string().trim().max(500).optional().label("Search Text"),
36
+
37
+ /**
38
+ * Field filters, in the same `{ field: values }` shape the search endpoint
39
+ * parses from `filter[field]=a,b`. Held as a free-form object rather than a
40
+ * closed schema because the job facets it addresses live in jobs-service and
41
+ * change independently; validating them here would put this package in the
42
+ * business of tracking that vocabulary.
43
+ */
44
+ filter: object().optional().default({}).label("Filters"),
45
+
46
+ /**
47
+ * How often to mail matches. `off` keeps the search saved but silent, which is
48
+ * the difference between "I don't want mail" and "delete my search".
49
+ *
50
+ * Reuses the cadence enum from `marketingConsent.jobAlertFrequency` rather
51
+ * than defining a second one: two vocabularies for the same concept is how
52
+ * they drift.
53
+ */
54
+ frequency: string()
55
+ .oneOf(SupportedJobAlertFrequencies)
56
+ .default(JobAlertFrequency.Off)
57
+ .label("Alert Frequency"),
58
+
59
+ status: string()
60
+ .oneOf(SupportedSavedSearchStatuses)
61
+ .default(SavedSearchStatus.Active)
62
+ .label("Status"),
63
+
64
+ /**
65
+ * Epoch ms of the last alert sent for this search. The matching sweep reads it
66
+ * to decide whether the cadence is due, and writes it only AFTER a successful
67
+ * publish, so a failed run retries next tick rather than silently skipping a
68
+ * period.
69
+ */
70
+ lastNotifiedAt: number().optional().label("Last Notified At"),
71
+
72
+ /**
73
+ * Epoch ms high-water mark: only jobs created after this are "new" for the
74
+ * next alert. Kept separate from `lastNotifiedAt` because a run that finds no
75
+ * matches should still advance what counts as new, without implying mail was
76
+ * sent.
77
+ */
78
+ lastMatchedAt: number().optional().label("Last Matched At"),
79
+ })
80
+ // `createdAt`/`updatedAt` are deliberately absent, matching every other schema
81
+ // here: audit timestamps are the storing service's concern, not part of the
82
+ // shape a client sends or validates.
83
+ .noUnknown()
84
+ .strict();
@@ -97,6 +97,29 @@ export enum JobAlertFrequency {
97
97
 
98
98
  export const SupportedJobAlertFrequencies = Object.values(JobAlertFrequency);
99
99
 
100
+ /**
101
+ * Channels of NOTIFICATION email — mail about activity on the user's own
102
+ * account (a group invite, a job of theirs expiring). Distinct from marketing:
103
+ * notifications are opt-OUT (default on, see `notificationPrefs`), marketing is
104
+ * opt-IN (`marketingConsent.subscribed`). Transactional mail (magic-link,
105
+ * verification) is governed by neither and always sends.
106
+ *
107
+ * Deliberately coarse: four channels a user will actually reason about, rather
108
+ * than one toggle per email template.
109
+ */
110
+ export enum NotificationChannel {
111
+ /** Group invites, join requests, membership approved/rejected. */
112
+ GroupActivity = "groupActivity",
113
+ /** Activity on jobs the user posted, e.g. a listing expiring. */
114
+ JobActivity = "jobActivity",
115
+ /** Status changes on jobs the user applied to. */
116
+ ApplicationUpdates = "applicationUpdates",
117
+ /** Product announcements and feature news. */
118
+ ProductUpdates = "productUpdates",
119
+ }
120
+
121
+ export const SupportedNotificationChannels = Object.values(NotificationChannel);
122
+
100
123
  /** ISO-4217 currency codes accepted for the salary preference (extend as needed). */
101
124
  export const SupportedSalaryCurrencies = ["USD", "EUR", "GBP", "SEK", "INR"] as const;
102
125
  export type SupportedSalaryCurrency = (typeof SupportedSalaryCurrencies)[number];
@@ -141,6 +141,33 @@ export const UserSchema = object({
141
141
  .default({ subscribed: false })
142
142
  .label("Marketing Consent"),
143
143
 
144
+ /**
145
+ * Per-channel toggles for NOTIFICATION email — mail about activity on the
146
+ * user's own account (a group invite, a job of theirs expiring).
147
+ *
148
+ * Deliberately separate from `marketingConsent`, which they must NOT be
149
+ * collapsed into: the two have different legal footing, which the
150
+ * email-service footer rules already encode. Notifications are opt-OUT
151
+ * (default `true`) because a user who declined marketing still needs to hear
152
+ * that their group invite arrived; marketing stays opt-IN. `productUpdates`
153
+ * is the exception — it is promotional in character, so it defaults off.
154
+ *
155
+ * Transactional mail (magic-link, verification) ignores this object entirely.
156
+ */
157
+ notificationPrefs: object({
158
+ groupActivity: boolean().default(true).label("Group activity email"),
159
+ jobActivity: boolean().default(true).label("Job activity email"),
160
+ applicationUpdates: boolean().default(true).label("Application update email"),
161
+ productUpdates: boolean().default(false).label("Product update email"),
162
+ })
163
+ .default({
164
+ groupActivity: true,
165
+ jobActivity: true,
166
+ applicationUpdates: true,
167
+ productUpdates: false,
168
+ })
169
+ .label("Notification Preferences"),
170
+
144
171
  /**
145
172
  * Vector embedding of the user's profile for semantic/hybrid search.
146
173
  * Generated from a structured summary of skills, experience, education, etc.