@read-frog/api-contract 0.21.1 → 0.23.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@read-frog/api-contract",
3
- "version": "0.21.1",
3
+ "version": "0.23.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@orpc/contract": "^1.15.2",
28
- "@read-frog/definitions": "0.10.1",
28
+ "@read-frog/definitions": "0.11.0",
29
29
  "zod": "^4.6.5"
30
30
  },
31
31
  "devDependencies": {
@@ -45,7 +45,9 @@ export const ebookContract = {
45
45
  .output(ebookGetOutputSchema),
46
46
 
47
47
  requestUpload: ebookProcedure
48
- .errors(pickPublicErrorMap("EBOOK_BETA_RESTRICTED", "EBOOK_LIMIT_EXCEEDED"))
48
+ .errors(
49
+ pickPublicErrorMap("EBOOK_BETA_RESTRICTED", "EBOOK_LIMIT_EXCEEDED", "EBOOK_FILE_TOO_LARGE"),
50
+ )
49
51
  .route({
50
52
  method: "POST",
51
53
  path: "/ebooks/uploads",
@@ -0,0 +1,55 @@
1
+ import { oc } from "@orpc/contract";
2
+ import { pickPublicErrorMap } from "#/public-errors";
3
+ import {
4
+ emailPreferencesByTokenStateSchema,
5
+ emailPreferencesGetByTokenInputSchema,
6
+ emailPreferencesGetInputSchema,
7
+ emailPreferencesStateSchema,
8
+ emailPreferencesUpdateByTokenInputSchema,
9
+ emailPreferencesUpdateInputSchema,
10
+ } from "#/schemas/email-preferences";
11
+
12
+ // Every write returns the whole state, so a client can update optimistically
13
+ // and then take the server's answer.
14
+ export const emailPreferencesContract = {
15
+ get: oc
16
+ .route({
17
+ method: "GET",
18
+ path: "/users/me/email-preferences",
19
+ summary: "Get which emails the authenticated user receives",
20
+ tags: ["Email preferences"],
21
+ })
22
+ .input(emailPreferencesGetInputSchema)
23
+ .output(emailPreferencesStateSchema),
24
+ update: oc
25
+ .route({
26
+ method: "PATCH",
27
+ path: "/users/me/email-preferences",
28
+ summary: "Change which emails the authenticated user receives",
29
+ tags: ["Email preferences"],
30
+ })
31
+ .input(emailPreferencesUpdateInputSchema)
32
+ .output(emailPreferencesStateSchema),
33
+ // Public: the token in an email's link stands in for signing in. POST keeps
34
+ // the token out of URLs and logs.
35
+ getByToken: oc
36
+ .route({
37
+ method: "POST",
38
+ path: "/email-preferences/by-token",
39
+ summary: "Get the email preferences an email link is for",
40
+ tags: ["Email preferences"],
41
+ })
42
+ .errors(pickPublicErrorMap("EMAIL_LINK_INVALID"))
43
+ .input(emailPreferencesGetByTokenInputSchema)
44
+ .output(emailPreferencesByTokenStateSchema),
45
+ updateByToken: oc
46
+ .route({
47
+ method: "POST",
48
+ path: "/email-preferences/by-token/update",
49
+ summary: "Change the email preferences an email link is for",
50
+ tags: ["Email preferences"],
51
+ })
52
+ .errors(pickPublicErrorMap("EMAIL_LINK_INVALID"))
53
+ .input(emailPreferencesUpdateByTokenInputSchema)
54
+ .output(emailPreferencesByTokenStateSchema),
55
+ };
@@ -0,0 +1,17 @@
1
+ import { oc } from "@orpc/contract";
2
+ import {
3
+ FeatureAccessStatusInputSchema,
4
+ FeatureAccessStatusOutputSchema,
5
+ } from "#/schemas/feature-access";
6
+
7
+ export const featureAccessContract = {
8
+ status: oc
9
+ .route({
10
+ method: "GET",
11
+ path: "/feature-access/status",
12
+ summary: "Get whether the caller can use a gated feature",
13
+ tags: ["Feature Access"],
14
+ })
15
+ .input(FeatureAccessStatusInputSchema)
16
+ .output(FeatureAccessStatusOutputSchema),
17
+ };
@@ -13,6 +13,8 @@ import {
13
13
  NotebaseGetSummaryOutputSchema,
14
14
  NotebaseListInputSchema,
15
15
  NotebaseListOutputSchema,
16
+ NotebaseSetSleepInputSchema,
17
+ NotebaseSetSleepOutputSchema,
16
18
  NotebaseUpdateInputSchema,
17
19
  NotebaseUpdateOutputSchema,
18
20
  } from "#/schemas/notebase";
@@ -89,6 +91,18 @@ export const notebaseContract = {
89
91
  .input(NotebaseUpdateInputSchema)
90
92
  .output(NotebaseUpdateOutputSchema),
91
93
 
94
+ setSleep: defineAgentProcedure({
95
+ method: "POST",
96
+ path: "/notebases/{id}/sleep",
97
+ summary: "Set notebase sleep",
98
+ description:
99
+ "Put an owned notebase to sleep (enabled=true) or wake it (enabled=false). A sleeping notebase is left out of the combined review; its cards, memory states and review history are unchanged and it can still be reviewed directly. A request matching the current state succeeds without a change.",
100
+ tags: ["Notebases"],
101
+ })
102
+ .errors(pickPublicErrorMap("NOTEBASE_NOT_FOUND"))
103
+ .input(NotebaseSetSleepInputSchema)
104
+ .output(NotebaseSetSleepOutputSchema),
105
+
92
106
  delete: defineAgentProcedure({
93
107
  method: "DELETE",
94
108
  path: "/notebases/{id}",
@@ -37,7 +37,7 @@ export const speechContract = {
37
37
  .route({
38
38
  method: "POST",
39
39
  path: "/speech/synthesize",
40
- summary: "Read a text aloud, as MP3 audio",
40
+ summary: "Read a text aloud, and link to its MP3 audio",
41
41
  tags: ["Speech"],
42
42
  })
43
43
  .input(speechSynthesizeInputSchema)
@@ -1,7 +1,11 @@
1
1
  import { oc } from "@orpc/contract";
2
2
  import {
3
+ userEnsureLocaleInputSchema,
4
+ userEnsureLocaleOutputSchema,
3
5
  userEnsureTimezoneInputSchema,
4
6
  userEnsureTimezoneOutputSchema,
7
+ userUpdateLocaleInputSchema,
8
+ userUpdateLocaleOutputSchema,
5
9
  userUpdateTimezoneInputSchema,
6
10
  userUpdateTimezoneOutputSchema,
7
11
  } from "#/schemas/user";
@@ -25,4 +29,22 @@ export const userContract = {
25
29
  })
26
30
  .input(userUpdateTimezoneInputSchema)
27
31
  .output(userUpdateTimezoneOutputSchema),
32
+ ensureLocale: oc
33
+ .route({
34
+ method: "POST",
35
+ path: "/users/me/locale/ensure",
36
+ summary: "Ensure the authenticated user has a locale",
37
+ tags: ["Users"],
38
+ })
39
+ .input(userEnsureLocaleInputSchema)
40
+ .output(userEnsureLocaleOutputSchema),
41
+ updateLocale: oc
42
+ .route({
43
+ method: "PATCH",
44
+ path: "/users/me/locale",
45
+ summary: "Update the authenticated user's locale",
46
+ tags: ["Users"],
47
+ })
48
+ .input(userUpdateLocaleInputSchema)
49
+ .output(userUpdateLocaleOutputSchema),
28
50
  };
package/src/index.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import type { ContractRouterClient } from "@orpc/contract";
2
- import { betaAccessContract } from "./contracts/beta-access";
3
2
  import { billingContract } from "./contracts/billing";
4
3
  import { cardContract } from "./contracts/card";
5
4
  import { cardTemplateContract } from "./contracts/card-template";
6
5
  import { ebookContract } from "./contracts/ebook";
6
+ import { emailPreferencesContract } from "./contracts/email-preferences";
7
+ import { featureAccessContract } from "./contracts/feature-access";
7
8
  import { hostedAiContract } from "./contracts/hosted-ai";
8
9
  import { masteryGoalContract } from "./contracts/mastery-goal";
9
10
  import { mcpConnectionContract } from "./contracts/mcp-connection";
@@ -17,11 +18,12 @@ import { userContract } from "./contracts/user";
17
18
  import { videoTranscriptContract } from "./contracts/video-transcript";
18
19
 
19
20
  export const contract = {
20
- betaAccess: betaAccessContract,
21
21
  billing: billingContract,
22
22
  card: cardContract,
23
23
  cardTemplate: cardTemplateContract,
24
24
  ebook: ebookContract,
25
+ emailPreferences: emailPreferencesContract,
26
+ featureAccess: featureAccessContract,
25
27
  hostedAi: hostedAiContract,
26
28
  masteryGoal: masteryGoalContract,
27
29
  mcpConnection: mcpConnectionContract,
@@ -39,10 +41,12 @@ export type ORPCRouterClient = ContractRouterClient<typeof contract>;
39
41
 
40
42
  export { type AgentRoute, defineAgentProcedure } from "./contracts/shared";
41
43
  export * from "./public-errors";
42
- export * from "./schemas/beta-access";
44
+ export * from "./schemas/app-locale";
43
45
  export * from "./schemas/billing";
44
46
  export * from "./schemas/card";
45
47
  export * from "./schemas/ebook";
48
+ export * from "./schemas/email-preferences";
49
+ export * from "./schemas/feature-access";
46
50
  export * from "./schemas/hosted-ai";
47
51
  export * from "./schemas/mastery-goal";
48
52
  export * from "./schemas/mcp-connection";
@@ -1,6 +1,10 @@
1
1
  import type { ErrorMap } from "@orpc/contract";
2
2
  import { z } from "zod";
3
- import { HostedAiCreditPolicyIdSchema, HostedAiModelTierSchema } from "./schemas/hosted-ai";
3
+ import {
4
+ HostedAiCreditPolicyIdSchema,
5
+ HostedAiModelTierSchema,
6
+ HostedAiUpgradePlanSchema,
7
+ } from "./schemas/hosted-ai";
4
8
 
5
9
  interface PublicAppErrorDefinition {
6
10
  data?: z.ZodTypeAny;
@@ -74,6 +78,8 @@ export type CardTemplateNotebaseColumnInUseData = z.infer<
74
78
 
75
79
  export const HostedAiTierRestrictedDataSchema = z.strictObject({
76
80
  modelTier: HostedAiModelTierSchema,
81
+ /** The cheapest plan that unlocks it; a client offers "Pro or Ultra" for `pro`. */
82
+ upgradeTo: HostedAiUpgradePlanSchema,
77
83
  });
78
84
 
79
85
  export const HostedAiQuotaExhaustedDataSchema = z.strictObject({
@@ -112,21 +118,10 @@ export const PUBLIC_APP_ERROR_DEFS = {
112
118
  message: "Built-in AI credit exhausted",
113
119
  status: 429,
114
120
  },
115
- NOTEBASE_BETA_RESTRICTED: {
116
- message: "Notebase is currently in beta for selected accounts",
117
- status: 403,
118
- },
119
121
  NOTEBASE_NOT_FOUND: {
120
122
  message: "Notebase not found",
121
123
  status: 404,
122
124
  },
123
- // Retired at launch (2026-08-14) but kept defined for one release so old
124
- // clients' error maps keep parsing; the server no longer emits it. Remove
125
- // together with the beta feature key in the follow-up cleanup.
126
- VIDEO_TRANSCRIPTION_BETA_RESTRICTED: {
127
- message: "Video transcription is currently in beta for selected accounts",
128
- status: 403,
129
- },
130
125
  VIDEO_TRANSCRIPTION_SUBSCRIPTION_REQUIRED: {
131
126
  message: "AI subtitles require a Pro or Ultra subscription",
132
127
  status: 403,
@@ -290,6 +285,12 @@ export const PUBLIC_APP_ERROR_DEFS = {
290
285
  message: "Ebook upload has not been completed",
291
286
  status: 409,
292
287
  },
288
+ // An email's unsubscribe or preferences link that does not verify, or whose
289
+ // account no longer exists. The page asks the reader to sign in instead.
290
+ EMAIL_LINK_INVALID: {
291
+ message: "This email link is not valid",
292
+ status: 400,
293
+ },
293
294
  } as const satisfies Record<string, PublicAppErrorDefinition>;
294
295
 
295
296
  export type PublicAppErrorCode = keyof typeof PUBLIC_APP_ERROR_DEFS;
@@ -0,0 +1,9 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * The languages the website is in (`project.inlang/settings.json`; a test in
5
+ * apps/www keeps the two in step). Used wherever the server writes for a
6
+ * person: Stripe pages, emails, and the account's `locale`.
7
+ */
8
+ export const appLocaleSchema = z.enum(["en", "es", "ja", "ko", "ru", "tr", "vi", "zh", "zh-TW"]);
9
+ export type AppLocale = z.infer<typeof appLocaleSchema>;
@@ -1,4 +1,6 @@
1
+ import type { AppLocale } from "./app-locale";
1
2
  import { z } from "zod";
3
+ import { appLocaleSchema } from "./app-locale";
2
4
 
3
5
  export const billingIntervalSchema = z.enum(["month", "year"]);
4
6
  export type BillingInterval = z.infer<typeof billingIntervalSchema>;
@@ -19,30 +21,20 @@ export const billingPlanSchema = z.enum(["free", "pro", "ultra"]);
19
21
  export type BillingPlan = z.infer<typeof billingPlanSchema>;
20
22
 
21
23
  /**
22
- * Whether a plan may use a feature, given the plans that feature allows.
24
+ * How many MCP tool calls each plan may make in a day (24 hours from the first
25
+ * one); `null` is no limit. Each `tools/call` counts once, whatever the tool.
23
26
  *
24
- * An explicit set rather than a minimum tier: there is no rank table to keep in
25
- * step with the plan enum, and any combination is expressible by writing it
26
- * down. The cost is that nothing derives from it automatically -- a feature's
27
- * pricing-card placement is a companion edit, noted at each `*_ALLOWED_PLANS`.
28
- */
29
- export function isPlanAllowed(plan: BillingPlan, allowed: readonly BillingPlan[]): boolean {
30
- return allowed.includes(plan);
31
- }
32
-
33
- /**
34
- * Plans that may use the remote MCP server.
27
+ * Read by the MCP server's gate AND by the website's copy, so the pages cannot
28
+ * promise calls the server refuses.
35
29
  *
36
- * Read by the server-side gate AND by the website's copy, so the page cannot
37
- * promise access the API refuses. `satisfies` keeps the literal tuple type so
38
- * the UI can render the plan names from this constant instead of hardcoding
39
- * them.
40
- *
41
- * Companion edit when this changes: the feature bullet in
42
- * `apps/www/src/routes/pricing.tsx` lives on the lowest allowed plan's card
43
- * (higher cards inherit it through their "everything in <lower plan>" line).
30
+ * Companion edit when this changes: the MCP docs under
31
+ * `apps/www/content/docs/mcp/` and `account-and-plans`, which state the numbers.
44
32
  */
45
- export const MCP_ALLOWED_PLANS = ["ultra"] as const satisfies readonly BillingPlan[];
33
+ export const MCP_DAILY_CALL_LIMITS = {
34
+ free: 10,
35
+ pro: 50,
36
+ ultra: null,
37
+ } as const satisfies Record<BillingPlan, number | null>;
46
38
 
47
39
  // Plans a user can actively check out into (the free tier has no price).
48
40
  export const billingCheckoutPlanSchema = z.enum(["pro", "ultra"]);
@@ -51,18 +43,10 @@ export type BillingCheckoutPlan = z.infer<typeof billingCheckoutPlanSchema>;
51
43
  export const billingCurrencySchema = z.enum(["usd", "cad", "cny"]);
52
44
  export type BillingCurrency = z.infer<typeof billingCurrencySchema>;
53
45
 
54
- export const billingLocaleSchema = z.enum([
55
- "en",
56
- "es",
57
- "ja",
58
- "ko",
59
- "ru",
60
- "tr",
61
- "vi",
62
- "zh",
63
- "zh-TW",
64
- ]);
65
- export type BillingLocale = z.infer<typeof billingLocaleSchema>;
46
+ // The billing name of `appLocaleSchema`, which it was before the account had a
47
+ // locale of its own; kept so existing imports keep working.
48
+ export const billingLocaleSchema = appLocaleSchema;
49
+ export type BillingLocale = AppLocale;
66
50
 
67
51
  export const billingAccessSourceSchema = z.enum(["subscription", "pass"]);
68
52
  export type BillingAccessSource = z.infer<typeof billingAccessSourceSchema>;
@@ -76,6 +60,33 @@ export const billingScheduledChangeSchema = z
76
60
  .strict();
77
61
  export type BillingScheduledChange = z.infer<typeof billingScheduledChangeSchema>;
78
62
 
63
+ /**
64
+ * The zone a payment deadline is shown in, as a date with no time. UTC−12 is
65
+ * the last zone to finish a day, and `dueBy` is the end of a day there, so the
66
+ * date it shows stays open in every customer's zone until their own midnight.
67
+ */
68
+ export const BILLING_PAYMENT_DUE_TIME_ZONE = "Etc/GMT+12";
69
+
70
+ /**
71
+ * A renewal whose payment failed. The plan stays until `dueBy`; paying the
72
+ * invoice before then (or later, until the subscription is canceled) restores
73
+ * the subscription as if the payment had gone through.
74
+ */
75
+ export const billingPaymentDueSchema = z
76
+ .object({
77
+ /** The last instant of the plan; show it in BILLING_PAYMENT_DUE_TIME_ZONE. */
78
+ dueBy: z.coerce.date(),
79
+ /**
80
+ * The plan the payment keeps. Not `BillingStatus.plan`: past `dueBy` that
81
+ * already reads "free" while the invoice is still payable.
82
+ */
83
+ plan: billingCheckoutPlanSchema,
84
+ /** The Stripe-hosted page that pays the invoice; null if Stripe gave none. */
85
+ url: z.url().nullable(),
86
+ })
87
+ .strict();
88
+ export type BillingPaymentDue = z.infer<typeof billingPaymentDueSchema>;
89
+
79
90
  export const billingStatusSchema = z
80
91
  .object({
81
92
  plan: billingPlanSchema,
@@ -91,6 +102,7 @@ export const billingStatusSchema = z
91
102
  cancelAt: z.coerce.date().nullable(),
92
103
  accessSource: billingAccessSourceSchema.nullable(),
93
104
  scheduledChange: billingScheduledChangeSchema.nullable(),
105
+ paymentDue: billingPaymentDueSchema.nullable(),
94
106
  unlimitedNotebaseNotes: z.boolean(),
95
107
  notebaseNoteLimit: z.number().int().positive(),
96
108
  notebaseNoteCount: z.number().int().min(0),
@@ -102,7 +114,7 @@ export const billingCreateCheckoutSessionInputSchema = z
102
114
  .object({
103
115
  currency: billingCurrencySchema,
104
116
  interval: billingIntervalSchema,
105
- locale: billingLocaleSchema,
117
+ locale: appLocaleSchema,
106
118
  plan: billingCheckoutPlanSchema.default("pro"),
107
119
  })
108
120
  .strict();
@@ -112,7 +124,7 @@ export type BillingCreateCheckoutSessionInput = z.infer<
112
124
 
113
125
  export const billingCreateCustomerPortalSessionInputSchema = z
114
126
  .object({
115
- locale: billingLocaleSchema,
127
+ locale: appLocaleSchema,
116
128
  })
117
129
  .strict();
118
130
  export type BillingCreateCustomerPortalSessionInput = z.infer<
@@ -68,28 +68,36 @@ export const ebookRequestUploadInputSchema = z
68
68
  title: z.string().min(1).max(500),
69
69
  author: z.string().max(500).optional(),
70
70
  language: z.string().max(35).optional(),
71
- coverContentType: ebookCoverContentTypeSchema.optional(),
71
+ // Exact sizes: each upload is signed for them, and storage refuses any other file.
72
+ fileByteSize: z.number().int().min(1),
73
+ cover: z
74
+ .object({
75
+ contentType: ebookCoverContentTypeSchema,
76
+ byteSize: z.number().int().min(1),
77
+ })
78
+ .strict()
79
+ .optional(),
72
80
  })
73
81
  .strict();
74
82
  export type EbookRequestUploadInput = z.infer<typeof ebookRequestUploadInputSchema>;
75
83
 
76
84
  /**
77
- * S3 POST form policy. `fields` must be appended to the form in iteration order
78
- * with the file last; S3 ignores anything sent after the `file` part.
85
+ * A signed PUT: send the file to `url` with exactly `headers`. The signature
86
+ * covers the file's type and exact size, so storage refuses any other file.
79
87
  */
80
- export const ebookPresignedPostSchema = z
88
+ export const ebookPresignedUploadSchema = z
81
89
  .object({
82
90
  url: z.url(),
83
- fields: z.record(z.string(), z.string()),
91
+ headers: z.record(z.string(), z.string()),
84
92
  })
85
93
  .strict();
86
- export type EbookPresignedPost = z.infer<typeof ebookPresignedPostSchema>;
94
+ export type EbookPresignedUpload = z.infer<typeof ebookPresignedUploadSchema>;
87
95
 
88
96
  export const ebookRequestUploadOutputSchema = z
89
97
  .object({
90
98
  ebookId: z.uuid(),
91
- upload: ebookPresignedPostSchema,
92
- coverUpload: ebookPresignedPostSchema.nullable(),
99
+ upload: ebookPresignedUploadSchema,
100
+ coverUpload: ebookPresignedUploadSchema.nullable(),
93
101
  expiresAt: z.coerce.date(),
94
102
  })
95
103
  .strict();
@@ -0,0 +1,105 @@
1
+ import { z } from "zod";
2
+ import { appLocaleSchema } from "./app-locale";
3
+
4
+ // Which marketing and lifecycle emails a user gets, by category. Read and
5
+ // changed signed in (the settings page), or through the signed token in an
6
+ // email's unsubscribe link (the unsubscribe page that link opens, which needs
7
+ // no sign-in). Category names come from the server in the requested locale.
8
+
9
+ /** A category's key, such as `promotions`. */
10
+ export const emailCategoryKeySchema = z
11
+ .string()
12
+ .max(64)
13
+ .regex(/^[a-z][a-z0-9_]*$/);
14
+ export type EmailCategoryKey = z.infer<typeof emailCategoryKeySchema>;
15
+
16
+ /** The token in an email's unsubscribe link. */
17
+ export const emailLinkTokenSchema = z.string().min(1).max(1024);
18
+
19
+ export const emailCategoryStateSchema = z
20
+ .object({
21
+ key: z.string(),
22
+ // In the requested locale, or English when the category has none for it.
23
+ name: z.string(),
24
+ description: z.string().nullable(),
25
+ // Promotional mail.
26
+ commercial: z.boolean(),
27
+ // Notices about the account and the service, which nobody can unsubscribe
28
+ // from: always subscribed, "unsubscribe from all" included, and a change
29
+ // to it is ignored. Show it on, without a way to turn it off.
30
+ required: z.boolean(),
31
+ // Whether the user gets this category now, "unsubscribe from all" included.
32
+ subscribed: z.boolean(),
33
+ })
34
+ .strict();
35
+ export type EmailCategoryState = z.infer<typeof emailCategoryStateSchema>;
36
+
37
+ export const emailPreferencesStateSchema = z
38
+ .object({
39
+ // In display order; archived categories are left out.
40
+ categories: z.array(emailCategoryStateSchema),
41
+ unsubscribedAll: z.boolean(),
42
+ })
43
+ .strict();
44
+ export type EmailPreferencesState = z.infer<typeof emailPreferencesStateSchema>;
45
+
46
+ /**
47
+ * What the page a link opens shows on top of the state: whose preferences they
48
+ * are, without the full address, and the category the link was for, if any.
49
+ */
50
+ export const emailPreferencesByTokenStateSchema = z
51
+ .object({
52
+ ...emailPreferencesStateSchema.shape,
53
+ maskedEmail: z.string(),
54
+ // The link's category, when it is one the user can unsubscribe from: null
55
+ // for a link without one, or for an archived, required or unknown one.
56
+ highlightCategory: z.string().nullable(),
57
+ })
58
+ .strict();
59
+ export type EmailPreferencesByTokenState = z.infer<typeof emailPreferencesByTokenStateSchema>;
60
+
61
+ const emailPreferencesChangesShape = {
62
+ // Subscribes (true) or unsubscribes (false) the categories it names, and
63
+ // leaves the rest alone. Subscribing to one after "unsubscribe from all"
64
+ // ends it, and every other category stays unsubscribed. A required category
65
+ // is ignored.
66
+ categories: z.record(emailCategoryKeySchema, z.boolean()).optional(),
67
+ // true unsubscribes from every category but the required ones, including
68
+ // categories added later; false ends that and leaves each category as it was
69
+ // before.
70
+ unsubscribedAll: z.boolean().optional(),
71
+ };
72
+
73
+ export const emailPreferencesGetInputSchema = z
74
+ .object({
75
+ locale: appLocaleSchema,
76
+ })
77
+ .strict();
78
+ export type EmailPreferencesGetInput = z.infer<typeof emailPreferencesGetInputSchema>;
79
+
80
+ export const emailPreferencesUpdateInputSchema = z
81
+ .object({
82
+ locale: appLocaleSchema,
83
+ ...emailPreferencesChangesShape,
84
+ })
85
+ .strict();
86
+ export type EmailPreferencesUpdateInput = z.infer<typeof emailPreferencesUpdateInputSchema>;
87
+
88
+ export const emailPreferencesGetByTokenInputSchema = z
89
+ .object({
90
+ token: emailLinkTokenSchema,
91
+ locale: appLocaleSchema,
92
+ })
93
+ .strict();
94
+ export type EmailPreferencesGetByTokenInput = z.infer<typeof emailPreferencesGetByTokenInputSchema>;
95
+
96
+ export const emailPreferencesUpdateByTokenInputSchema = z
97
+ .object({
98
+ token: emailLinkTokenSchema,
99
+ locale: appLocaleSchema,
100
+ ...emailPreferencesChangesShape,
101
+ })
102
+ .strict();
103
+ export type EmailPreferencesUpdateByTokenInput = z.infer<
104
+ typeof emailPreferencesUpdateByTokenInputSchema
105
+ >;
@@ -0,0 +1,18 @@
1
+ import { GATED_FEATURE_KEYS } from "@read-frog/definitions";
2
+ import { z } from "zod";
3
+
4
+ export const GatedFeatureKeySchema = z.enum(GATED_FEATURE_KEYS);
5
+ export type GatedFeatureKey = z.infer<typeof GatedFeatureKeySchema>;
6
+
7
+ export const FeatureAccessStatusInputSchema = z
8
+ .object({
9
+ featureKey: GatedFeatureKeySchema,
10
+ })
11
+ .strict();
12
+ export type FeatureAccessStatusInput = z.infer<typeof FeatureAccessStatusInputSchema>;
13
+
14
+ export const FeatureAccessStatusOutputSchema = z.object({
15
+ featureKey: GatedFeatureKeySchema,
16
+ allowed: z.boolean(),
17
+ });
18
+ export type FeatureAccessStatusOutput = z.infer<typeof FeatureAccessStatusOutputSchema>;