founder-schema 0.4.0 → 0.5.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.
Files changed (44) hide show
  1. package/README.md +26 -1
  2. package/dist/core/errors.d.ts +13 -1
  3. package/dist/core/errors.d.ts.map +1 -1
  4. package/dist/core/errors.js +27 -3
  5. package/dist/core/errors.js.map +1 -1
  6. package/dist/core/limits.d.ts +27 -4
  7. package/dist/core/limits.d.ts.map +1 -1
  8. package/dist/core/limits.js +27 -4
  9. package/dist/core/limits.js.map +1 -1
  10. package/dist/core/responses.d.ts +12 -0
  11. package/dist/core/responses.d.ts.map +1 -1
  12. package/dist/core/validation.d.ts +2 -0
  13. package/dist/core/validation.d.ts.map +1 -1
  14. package/dist/core/validation.js +2 -0
  15. package/dist/core/validation.js.map +1 -1
  16. package/dist/domains/account/account.d.ts +2 -0
  17. package/dist/domains/account/account.d.ts.map +1 -1
  18. package/dist/domains/account/account.js +2 -0
  19. package/dist/domains/account/account.js.map +1 -1
  20. package/dist/domains/feed/feed.d.ts +62 -25
  21. package/dist/domains/feed/feed.d.ts.map +1 -1
  22. package/dist/domains/feed/feed.js +23 -20
  23. package/dist/domains/feed/feed.js.map +1 -1
  24. package/dist/domains/media/media.d.ts +134 -0
  25. package/dist/domains/media/media.d.ts.map +1 -0
  26. package/dist/domains/media/media.js +110 -0
  27. package/dist/domains/media/media.js.map +1 -0
  28. package/dist/domains/onboarding/onboarding.d.ts +11 -11
  29. package/dist/domains/onboarding/onboarding.d.ts.map +1 -1
  30. package/dist/domains/onboarding/onboarding.js +15 -15
  31. package/dist/domains/onboarding/onboarding.js.map +1 -1
  32. package/dist/domains/profile/founder-profile.d.ts +24 -0
  33. package/dist/domains/profile/founder-profile.d.ts.map +1 -1
  34. package/dist/domains/profile/founder-profile.js +23 -1
  35. package/dist/domains/profile/founder-profile.js.map +1 -1
  36. package/dist/domains/uploads/uploads.d.ts +52 -64
  37. package/dist/domains/uploads/uploads.d.ts.map +1 -1
  38. package/dist/domains/uploads/uploads.js +43 -60
  39. package/dist/domains/uploads/uploads.js.map +1 -1
  40. package/dist/index.d.ts +4 -3
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +6 -4
  43. package/dist/index.js.map +1 -1
  44. package/package.json +1 -1
@@ -171,20 +171,32 @@ export declare const feedAuthorSchema: z.ZodObject<{
171
171
  }, z.core.$strip>;
172
172
  export type FeedAuthor = z.infer<typeof feedAuthorSchema>;
173
173
  /**
174
+ * A post's image: a processed `mediaImageSchema` (every stored size, ready for
175
+ * `srcset`, plus a placeholder) with the post's own alt text.
176
+ *
174
177
  * `width` and `height` are required so a card can reserve the right box before
175
178
  * the image loads. Without them every image arrival shifts the feed under the
176
179
  * reader's thumb.
177
180
  *
181
+ * `thumbnailUrl` is the smallest stored size, for a grid or a notification.
182
+ *
178
183
  * URLs here come from our own upload pipeline, so they take a plain `z.url()`
179
184
  * rather than `httpUrlSchema`. ⚠️ If a "paste an image link" path is ever
180
185
  * added, these become member-supplied and must move to `httpUrlSchema`.
181
186
  */
182
187
  export declare const feedImageSchema: z.ZodObject<{
183
- kind: z.ZodLiteral<"image">;
188
+ id: z.ZodString;
184
189
  url: z.ZodURL;
185
- thumbnailUrl: z.ZodOptional<z.ZodURL>;
186
190
  width: z.ZodNumber;
187
191
  height: z.ZodNumber;
192
+ variants: z.ZodArray<z.ZodObject<{
193
+ width: z.ZodNumber;
194
+ height: z.ZodNumber;
195
+ url: z.ZodURL;
196
+ }, z.core.$strip>>;
197
+ placeholder: z.ZodOptional<z.ZodString>;
198
+ kind: z.ZodLiteral<"image">;
199
+ thumbnailUrl: z.ZodOptional<z.ZodURL>;
188
200
  alt: z.ZodOptional<z.ZodString>;
189
201
  }, z.core.$strip>;
190
202
  export type FeedImage = z.infer<typeof feedImageSchema>;
@@ -203,11 +215,18 @@ export type FeedVideo = z.infer<typeof feedVideoSchema>;
203
215
  * without inspecting fields.
204
216
  */
205
217
  export declare const feedMediaSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
206
- kind: z.ZodLiteral<"image">;
218
+ id: z.ZodString;
207
219
  url: z.ZodURL;
208
- thumbnailUrl: z.ZodOptional<z.ZodURL>;
209
220
  width: z.ZodNumber;
210
221
  height: z.ZodNumber;
222
+ variants: z.ZodArray<z.ZodObject<{
223
+ width: z.ZodNumber;
224
+ height: z.ZodNumber;
225
+ url: z.ZodURL;
226
+ }, z.core.$strip>>;
227
+ placeholder: z.ZodOptional<z.ZodString>;
228
+ kind: z.ZodLiteral<"image">;
229
+ thumbnailUrl: z.ZodOptional<z.ZodURL>;
211
230
  alt: z.ZodOptional<z.ZodString>;
212
231
  }, z.core.$strip>, z.ZodObject<{
213
232
  kind: z.ZodLiteral<"video">;
@@ -283,11 +302,18 @@ export declare const feedPostSchema: z.ZodObject<{
283
302
  }, z.core.$strip>;
284
303
  text: z.ZodString;
285
304
  media: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
286
- kind: z.ZodLiteral<"image">;
305
+ id: z.ZodString;
287
306
  url: z.ZodURL;
288
- thumbnailUrl: z.ZodOptional<z.ZodURL>;
289
307
  width: z.ZodNumber;
290
308
  height: z.ZodNumber;
309
+ variants: z.ZodArray<z.ZodObject<{
310
+ width: z.ZodNumber;
311
+ height: z.ZodNumber;
312
+ url: z.ZodURL;
313
+ }, z.core.$strip>>;
314
+ placeholder: z.ZodOptional<z.ZodString>;
315
+ kind: z.ZodLiteral<"image">;
316
+ thumbnailUrl: z.ZodOptional<z.ZodURL>;
291
317
  alt: z.ZodOptional<z.ZodString>;
292
318
  }, z.core.$strip>, z.ZodObject<{
293
319
  kind: z.ZodLiteral<"video">;
@@ -552,11 +578,18 @@ export declare const feedItemSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
552
578
  }, z.core.$strip>;
553
579
  text: z.ZodString;
554
580
  media: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
555
- kind: z.ZodLiteral<"image">;
581
+ id: z.ZodString;
556
582
  url: z.ZodURL;
557
- thumbnailUrl: z.ZodOptional<z.ZodURL>;
558
583
  width: z.ZodNumber;
559
584
  height: z.ZodNumber;
585
+ variants: z.ZodArray<z.ZodObject<{
586
+ width: z.ZodNumber;
587
+ height: z.ZodNumber;
588
+ url: z.ZodURL;
589
+ }, z.core.$strip>>;
590
+ placeholder: z.ZodOptional<z.ZodString>;
591
+ kind: z.ZodLiteral<"image">;
592
+ thumbnailUrl: z.ZodOptional<z.ZodURL>;
560
593
  alt: z.ZodOptional<z.ZodString>;
561
594
  }, z.core.$strip>, z.ZodObject<{
562
595
  kind: z.ZodLiteral<"video">;
@@ -927,11 +960,18 @@ export declare const feedPageSchema: z.ZodObject<{
927
960
  }, z.core.$strip>;
928
961
  text: z.ZodString;
929
962
  media: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
930
- kind: z.ZodLiteral<"image">;
963
+ id: z.ZodString;
931
964
  url: z.ZodURL;
932
- thumbnailUrl: z.ZodOptional<z.ZodURL>;
933
965
  width: z.ZodNumber;
934
966
  height: z.ZodNumber;
967
+ variants: z.ZodArray<z.ZodObject<{
968
+ width: z.ZodNumber;
969
+ height: z.ZodNumber;
970
+ url: z.ZodURL;
971
+ }, z.core.$strip>>;
972
+ placeholder: z.ZodOptional<z.ZodString>;
973
+ kind: z.ZodLiteral<"image">;
974
+ thumbnailUrl: z.ZodOptional<z.ZodURL>;
935
975
  alt: z.ZodOptional<z.ZodString>;
936
976
  }, z.core.$strip>, z.ZodObject<{
937
977
  kind: z.ZodLiteral<"video">;
@@ -1210,28 +1250,25 @@ export type CommentPage = z.infer<typeof commentPageSchema>;
1210
1250
  * every request. See `updateFounderProfileBodySchema` for the incident.
1211
1251
  */
1212
1252
  /**
1213
- * An attachment on the way IN: a reference to something already uploaded, and
1214
- * deliberately not a URL.
1215
- *
1216
- * Upload happens first, straight to storage, and the composer sends back the
1217
- * key it was given (`uploadKeySchema`, in `uploads`). The server resolves that
1218
- * key to the URL, dimensions and poster frame it reads from the object itself,
1219
- * and those become the read shape.
1253
+ * An attachment on the way IN: the id of an image already uploaded and
1254
+ * completed (`media`), and deliberately not a URL.
1220
1255
  *
1221
1256
  * ⚠ The first draft of this took the full read shape, URL included. That is
1222
1257
  * the same hole that was closed on the profile: a URL in a write body is a
1223
1258
  * member-supplied URL, so a post could have carried `javascript:` in an image
1224
1259
  * source, and nothing downstream re-checks a value the server itself stored.
1225
- * Taking a key instead removes the question rather than validating it, and it
1226
- * also stops a member pointing a post at somebody else's file.
1260
+ * Taking an id instead removes the question rather than validating it.
1261
+ *
1262
+ * `width`, `height` and the URLs are NOT accepted either: the server measured
1263
+ * them when it processed the upload, and taking them from the client would let
1264
+ * a caller claim a 1x1 image and collapse the card.
1227
1265
  *
1228
- * `width`, `height` and `duration` are NOT accepted here either. They are
1229
- * properties of the uploaded object, so the server reads them from the object;
1230
- * taking them from the client would let a caller claim a 1x1 image and collapse
1231
- * the card.
1266
+ * The server attaches each id only if it is the caller's, processed, uploaded
1267
+ * for a post, and not attached anywhere yet (`UPLOAD_NOT_ATTACHABLE`
1268
+ * otherwise), so one upload can never appear on two posts.
1232
1269
  */
1233
1270
  export declare const attachedMediaSchema: z.ZodObject<{
1234
- uploadKey: z.ZodString;
1271
+ mediaId: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
1235
1272
  alt: z.ZodOptional<z.ZodString>;
1236
1273
  }, z.core.$strip>;
1237
1274
  export type AttachedMedia = z.infer<typeof attachedMediaSchema>;
@@ -1242,7 +1279,7 @@ export type AttachedMedia = z.infer<typeof attachedMediaSchema>;
1242
1279
  export declare const createPostBodySchema: z.ZodObject<{
1243
1280
  text: z.ZodOptional<z.ZodString>;
1244
1281
  media: z.ZodOptional<z.ZodArray<z.ZodObject<{
1245
- uploadKey: z.ZodString;
1282
+ mediaId: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
1246
1283
  alt: z.ZodOptional<z.ZodString>;
1247
1284
  }, z.core.$strip>>>;
1248
1285
  topics: z.ZodOptional<z.ZodArray<z.ZodEnum<{
@@ -1 +1 @@
1
- {"version":3,"file":"feed.d.ts","sourceRoot":"","sources":["../../../src/domains/feed/feed.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"feed.d.ts","sourceRoot":"","sources":["../../../src/domains/feed/feed.ts"],"names":[],"mappings":"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"}
@@ -33,8 +33,8 @@ import { emptyStringToUndefined, objectIdSchema, paginationQuerySchema, safeUrlS
33
33
  import { LINK_PREVIEW_DESCRIPTION_MAX, LINK_PREVIEW_TITLE_MAX, MEDIA_ALT_MAX, REPORT_NOTE_MAX, } from '../../core/limits.js';
34
34
  import { paginatedSchema } from '../../core/responses.js';
35
35
  import { VALIDATION_MESSAGE } from '../../core/validation.js';
36
+ import { mediaImageSchema } from '../media/media.js';
36
37
  import { topicSchema } from '../onboarding/onboarding.js';
37
- import { uploadKeySchema } from '../uploads/uploads.js';
38
38
  // ─── Limits ──────────────────────────────────────────────────────────────────
39
39
  /**
40
40
  * Exported because the composer must count down to the SAME number the server
@@ -159,20 +159,22 @@ export const feedAuthorSchema = z.object({
159
159
  });
160
160
  // ─── Media ───────────────────────────────────────────────────────────────────
161
161
  /**
162
+ * A post's image: a processed `mediaImageSchema` (every stored size, ready for
163
+ * `srcset`, plus a placeholder) with the post's own alt text.
164
+ *
162
165
  * `width` and `height` are required so a card can reserve the right box before
163
166
  * the image loads. Without them every image arrival shifts the feed under the
164
167
  * reader's thumb.
165
168
  *
169
+ * `thumbnailUrl` is the smallest stored size, for a grid or a notification.
170
+ *
166
171
  * URLs here come from our own upload pipeline, so they take a plain `z.url()`
167
172
  * rather than `httpUrlSchema`. ⚠️ If a "paste an image link" path is ever
168
173
  * added, these become member-supplied and must move to `httpUrlSchema`.
169
174
  */
170
- export const feedImageSchema = z.object({
175
+ export const feedImageSchema = mediaImageSchema.extend({
171
176
  kind: z.literal('image'),
172
- url: z.url(),
173
177
  thumbnailUrl: z.url().optional(),
174
- width: z.number().int().positive(),
175
- height: z.number().int().positive(),
176
178
  /** Author-supplied alt text. Optional, and worth asking for. */
177
179
  alt: z.string().max(MEDIA_ALT_MAX).optional(),
178
180
  });
@@ -447,28 +449,25 @@ export const commentPageSchema = paginatedSchema(feedCommentSchema);
447
449
  * every request. See `updateFounderProfileBodySchema` for the incident.
448
450
  */
449
451
  /**
450
- * An attachment on the way IN: a reference to something already uploaded, and
451
- * deliberately not a URL.
452
- *
453
- * Upload happens first, straight to storage, and the composer sends back the
454
- * key it was given (`uploadKeySchema`, in `uploads`). The server resolves that
455
- * key to the URL, dimensions and poster frame it reads from the object itself,
456
- * and those become the read shape.
452
+ * An attachment on the way IN: the id of an image already uploaded and
453
+ * completed (`media`), and deliberately not a URL.
457
454
  *
458
455
  * ⚠ The first draft of this took the full read shape, URL included. That is
459
456
  * the same hole that was closed on the profile: a URL in a write body is a
460
457
  * member-supplied URL, so a post could have carried `javascript:` in an image
461
458
  * source, and nothing downstream re-checks a value the server itself stored.
462
- * Taking a key instead removes the question rather than validating it, and it
463
- * also stops a member pointing a post at somebody else's file.
459
+ * Taking an id instead removes the question rather than validating it.
464
460
  *
465
- * `width`, `height` and `duration` are NOT accepted here either. They are
466
- * properties of the uploaded object, so the server reads them from the object;
467
- * taking them from the client would let a caller claim a 1x1 image and collapse
468
- * the card.
461
+ * `width`, `height` and the URLs are NOT accepted either: the server measured
462
+ * them when it processed the upload, and taking them from the client would let
463
+ * a caller claim a 1x1 image and collapse the card.
464
+ *
465
+ * The server attaches each id only if it is the caller's, processed, uploaded
466
+ * for a post, and not attached anywhere yet (`UPLOAD_NOT_ATTACHABLE`
467
+ * otherwise), so one upload can never appear on two posts.
469
468
  */
470
469
  export const attachedMediaSchema = z.object({
471
- uploadKey: uploadKeySchema,
470
+ mediaId: objectIdSchema,
472
471
  /** Author-supplied alt text, the one media field a member does write. */
473
472
  alt: z.string().trim().max(MEDIA_ALT_MAX).optional(),
474
473
  });
@@ -479,7 +478,7 @@ export const attachedMediaSchema = z.object({
479
478
  export const createPostBodySchema = z
480
479
  .object({
481
480
  text: z.string().trim().max(POST_MAX_BODY_CHARS).optional(),
482
- /** Already uploaded. See `attachedMediaSchema`. Order is preserved. */
481
+ /** Already uploaded and completed. See `attachedMediaSchema`. Order is preserved. */
483
482
  media: z.array(attachedMediaSchema).max(POST_MAX_MEDIA).optional(),
484
483
  /** Absent means untagged, which means interest matching cannot find it. */
485
484
  topics: z.array(topicSchema).max(MAX_TOPICS).optional(),
@@ -487,6 +486,10 @@ export const createPostBodySchema = z
487
486
  .refine((b) => Boolean(b.text?.length) || Boolean(b.media?.length), {
488
487
  error: VALIDATION_MESSAGE.postEmpty,
489
488
  path: ['text'],
489
+ })
490
+ .refine((b) => new Set(b.media?.map((m) => m.mediaId)).size === (b.media?.length ?? 0), {
491
+ error: VALIDATION_MESSAGE.mediaDuplicate,
492
+ path: ['media'],
490
493
  });
491
494
  /** Only the text is editable. Changing media after the fact makes every
492
495
  * earlier reply look like a reply to something else. */
@@ -1 +1 @@
1
- {"version":3,"file":"feed.js","sourceRoot":"","sources":["../../../src/domains/feed/feed.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * founder-schema — Feed wire shapes: post, poll, comment, reaction\n *\n * ADAPTED FROM `jansathi-community-schema`, NOT DEPENDENT ON IT.\n *\n * The user ruled on 2026-09-21 that Founder Circle is a standalone app, so\n * these shapes are copied and cut down rather than imported. The reason is\n * worth keeping: `jansathi-community-schema` encodes Jansathi's reach levels,\n * community designations, official-area kinds and crowdfunding audiences.\n * Depending on it would put all of that permanently inside our contract for a\n * feed that has none of those concepts, and would mean a Jansathi publish can\n * break this product.\n *\n * DELIBERATELY NOT PORTED, so nobody \"restores\" them:\n * - the five civic verticals (lost_found, suggestions_complaints,\n * donate_item, crowdfunding, event). `contentKind` here is post | poll\n * only; comment threads and bookmarks key off it, and reactions and\n * reports off the same union plus `comment`.\n * - official communities, designations, identity verification badges.\n * - social score, and the whole area-proximity ranking vocabulary\n * (local / district / state / national / global).\n * - `areaLineage` as a REQUIRED authorship snapshot. Jansathi gates its\n * composer on a declared residence resolving to Indian district level or\n * deeper; the user ruled location is never mandatory here, so nothing in\n * this file requires one.\n * - anonymous posting, which in Jansathi is gated on identity verification\n * we do not have. A founders' network is an identified network.\n *\n * @module founder-schema/domains/feed\n */\n\nimport { z } from 'zod';\nimport {\n emptyStringToUndefined,\n objectIdSchema,\n paginationQuerySchema,\n safeUrlSchema,\n usernameSchema,\n} from '../../core/common.js';\nimport {\n LINK_PREVIEW_DESCRIPTION_MAX,\n LINK_PREVIEW_TITLE_MAX,\n MEDIA_ALT_MAX,\n REPORT_NOTE_MAX,\n} from '../../core/limits.js';\nimport { paginatedSchema } from '../../core/responses.js';\nimport { VALIDATION_MESSAGE } from '../../core/validation.js';\nimport { topicSchema } from '../onboarding/onboarding.js';\nimport { uploadKeySchema } from '../uploads/uploads.js';\n\n// ─── Limits ──────────────────────────────────────────────────────────────────\n\n/**\n * Exported because the composer must count down to the SAME number the server\n * rejects at. A client-side limit that disagrees with the server is a message\n * a member cannot send and cannot see why.\n */\nexport const POST_MAX_BODY_CHARS = 5000;\n/**\n * Total attachments on one post, images and videos together.\n *\n * ONE cap over ONE ordered list, rather than Jansathi's separate image and\n * video arrays. A member who posts a screenshot, then a clip, then another\n * screenshot means that order, and two parallel arrays cannot express it: the\n * card would show both images and then the video, which is not what was\n * written.\n */\nexport const POST_MAX_MEDIA = 10;\n/**\n * Topics per item.\n *\n * Capped low on purpose: a post tagged with everything is about nothing, and\n * one tagged with ten matches every member, which is how a relevance feature\n * turns back into a chronological one.\n */\nexport const MAX_TOPICS = 3;\nexport const COMMENT_MAX_BODY_CHARS = 2000;\n\nexport const POLL_QUESTION_MAX_CHARS = 280;\nexport const POLL_OPTION_MAX_CHARS = 80;\nexport const POLL_MIN_OPTIONS = 2;\nexport const POLL_MAX_OPTIONS = 6;\n/** 5 minutes. Below this a poll closes before anyone sees it. */\nexport const POLL_MIN_DURATION_MS = 5 * 60 * 1000;\n/** 30 days. */\nexport const POLL_MAX_DURATION_MS = 30 * 24 * 60 * 60 * 1000;\n\n/**\n * How long after writing it the author may still edit a post or a comment.\n *\n * A window rather than forever: an edit after people have reacted changes what\n * they reacted to. Deletion has no window, because a member must always be able\n * to remove their own words. The server is the gate (`EDIT_WINDOW_CLOSED`);\n * a client uses this to stop offering Edit at the moment the server would\n * start refusing it, instead of on the next reload.\n */\nexport const EDIT_WINDOW_MS = 15 * 60 * 1000;\n\n// ─── Enums ───────────────────────────────────────────────────────────────────\n\n/**\n * What can appear in the feed. Two kinds, not seven.\n *\n * Engagement is keyed by `(contentKind, contentId)` so one engagement bar\n * serves both, and comments hang off the same pair. Adding a kind later is\n * additive; the union is deliberately narrow now so the client never has to\n * handle a kind this product does not have.\n */\nexport const CONTENT_KIND_VALUES = ['post', 'poll'] as const;\nexport const contentKindSchema = z.enum(CONTENT_KIND_VALUES);\nexport type ContentKind = z.infer<typeof contentKindSchema>;\n\n/**\n * What a reaction or a report can point at: a feed item, or a comment on one.\n *\n * Wider than `ContentKind` on purpose. A comment carries reaction counts and a\n * viewer reaction, and a comment can be abusive, so both reach it. A bookmark\n * and a comment thread cannot: they take `ContentKind`. Until 0.2.0 the\n * reaction and report RESPONSES were typed `ContentKind` while the routes\n * accepted comments, so every answer about a comment broke this contract.\n */\nexport const ENGAGEMENT_TARGET_KIND_VALUES = [...CONTENT_KIND_VALUES, 'comment'] as const;\nexport const engagementTargetKindSchema = z.enum(ENGAGEMENT_TARGET_KIND_VALUES);\nexport type EngagementTargetKind = z.infer<typeof engagementTargetKindSchema>;\n\n/**\n * Reactions. Jansathi's `support` is dropped: it exists for reacting to a\n * civic complaint or a lost-and-found notice, which this feed does not carry.\n *\n * ⚠️ These values are stored per reaction row, so removing one orphans data.\n * Append only.\n */\nexport const REACTION_TYPE_VALUES = ['like', 'celebrate', 'insightful', 'curious'] as const;\nexport const reactionTypeSchema = z.enum(REACTION_TYPE_VALUES);\nexport type ReactionType = z.infer<typeof reactionTypeSchema>;\n\n/**\n * Feed ordering.\n *\n * `new` is the default here, unlike Jansathi where `hot` leads. A hot ranking\n * needs volume to be meaningful, and a brand-new network ranking a handful of\n * posts by a blended score mostly hides the newest thing somebody just wrote.\n * Revisit when there is enough content for `hot` to mean anything.\n */\nexport const FEED_SORT_VALUES = ['new', 'top'] as const;\nexport const feedSortSchema = z.enum(FEED_SORT_VALUES);\nexport type FeedSort = z.infer<typeof feedSortSchema>;\n\n/**\n * Counts keyed by reaction type, e.g. `{ like: 4, celebrate: 1 }`. Only the\n * reactions an item actually has need appear.\n *\n * ⚠️ `partialRecord`, NOT `record`. In Zod 4 an enum-keyed `z.record` is\n * EXHAUSTIVE: it demands every key, so `{ like: 4 }` fails with three missing\n * keys and `{}` fails with four. That would force the server to ship a zero for\n * every reaction on every item, and the day a fifth reaction is added every\n * previously stored payload and every older client stops parsing.\n * `partialRecord` keeps the key validation without the exhaustiveness, so an\n * unknown key like Jansathi's `support` is still refused.\n */\nexport const reactionCountsSchema = z.partialRecord(\n reactionTypeSchema,\n z.number().int().nonnegative(),\n);\nexport type ReactionCounts = z.infer<typeof reactionCountsSchema>;\n\n// ─── Author byline ───────────────────────────────────────────────────────────\n\n/**\n * The author as a card renders them.\n *\n * Every field here is LIVE, joined against the profile at read time rather\n * than frozen onto the post at write time. So a renamed handle, a new avatar\n * or an edited headline updates on old posts, and the byline link always\n * targets the current username instead of 404ing on a handle that moved.\n *\n * Jansathi additionally snapshots `localName` (\"where the author was when\n * posting\"). Not ported: location is optional here, so a byline that depends\n * on one would be blank for anyone who never set it.\n */\nexport const feedAuthorSchema = z.object({\n userId: z.string(),\n username: usernameSchema,\n displayName: z.string(),\n /** Server-generated or from the login provider, never member-typed, so a\n * plain URL check. Same reasoning as `avatarUrl` on the profile. */\n avatarUrl: z.url().optional(),\n /** The author's one-line headline, live-joined. Gives a founders' byline\n * the context a civic byline got from an area label. */\n headline: z.string().optional(),\n});\nexport type FeedAuthor = z.infer<typeof feedAuthorSchema>;\n\n// ─── Media ───────────────────────────────────────────────────────────────────\n\n/**\n * `width` and `height` are required so a card can reserve the right box before\n * the image loads. Without them every image arrival shifts the feed under the\n * reader's thumb.\n *\n * URLs here come from our own upload pipeline, so they take a plain `z.url()`\n * rather than `httpUrlSchema`. ⚠️ If a \"paste an image link\" path is ever\n * added, these become member-supplied and must move to `httpUrlSchema`.\n */\nexport const feedImageSchema = z.object({\n kind: z.literal('image'),\n url: z.url(),\n thumbnailUrl: z.url().optional(),\n width: z.number().int().positive(),\n height: z.number().int().positive(),\n /** Author-supplied alt text. Optional, and worth asking for. */\n alt: z.string().max(MEDIA_ALT_MAX).optional(),\n});\nexport type FeedImage = z.infer<typeof feedImageSchema>;\n\nexport const feedVideoSchema = z.object({\n kind: z.literal('video'),\n url: z.url(),\n /** Poster frame, server-extracted. Required: a video with no poster is a\n * black rectangle in the feed. */\n thumbnailUrl: z.url(),\n width: z.number().int().positive(),\n height: z.number().int().positive(),\n /** Seconds. */\n duration: z.number().positive(),\n});\nexport type FeedVideo = z.infer<typeof feedVideoSchema>;\n\n/**\n * One ordered list, discriminated on `kind`, so the carousel renders\n * attachments in the order they were attached and can switch on the kind\n * without inspecting fields.\n */\nexport const feedMediaSchema = z.discriminatedUnion('kind', [feedImageSchema, feedVideoSchema]);\nexport type FeedMedia = z.infer<typeof feedMediaSchema>;\n\n// ─── Link preview ────────────────────────────────────────────────────────────\n\n/**\n * Preview for the first link in a post body. Server-derived at write time and\n * stored, never re-fetched at render: a card that fetches a third-party page\n * per scroll leaks every reader's address to that site and blocks the feed on\n * somebody else's latency.\n *\n * Both URLs take `safeUrlSchema`: the scheme and host rules, WITHOUT the\n * typed-URL cap. `url` came out of a post body, which is already capped as a\n * whole, and `imageUrl` is whatever the remote page advertised; a long CDN URL\n * is ordinary, and one over `LINK_URL_MAX` would otherwise fail the whole feed\n * page it sits on. Neither is ours, so a consumer still treats both as\n * untrusted at render.\n */\nexport const linkPreviewSchema = z.object({\n url: safeUrlSchema,\n title: z.string().max(LINK_PREVIEW_TITLE_MAX).optional(),\n description: z.string().max(LINK_PREVIEW_DESCRIPTION_MAX).optional(),\n imageUrl: safeUrlSchema.optional(),\n});\nexport type LinkPreview = z.infer<typeof linkPreviewSchema>;\n\n// ─── Viewer state ────────────────────────────────────────────────────────────\n\n/**\n * What THIS viewer has done to this item, computed per request.\n *\n * Optional as a block, because an unauthenticated or server-rendered read has\n * no viewer. ⚠️ A client must treat an absent `viewer` as \"no state known\",\n * never as \"has not reacted\": rendering an empty reaction button off a missing\n * block is how a member sees their own reaction disappear on reload.\n */\nexport const viewerStateSchema = z.object({\n reaction: reactionTypeSchema.nullable(),\n bookmarked: z.boolean(),\n isAuthor: z.boolean(),\n\n /**\n * What this viewer may DO, decided by the server and sent as plain answers.\n *\n * Not role data for the client to reason about. The moment a client derives\n * \"can I edit this\" from roles and ownership, that derivation is a second\n * implementation of the rule, it drifts from the server's, and the drift is\n * invisible until the two disagree: either a button that 403s or a button\n * that should not have been there. `isAuthor` stays because the byline reads\n * differently for your own post, not because the client should compute rights\n * from it.\n *\n * `canEdit` is false once the edit window closes, so it is not simply\n * `isAuthor`.\n */\n canEdit: z.boolean(),\n canDelete: z.boolean(),\n canReport: z.boolean(),\n});\nexport type ViewerState = z.infer<typeof viewerStateSchema>;\n\n// ─── Post ────────────────────────────────────────────────────────────────────\n\n/**\n * A post as the feed and the single-post page receive it.\n *\n * Counts are denormalised onto the row so one document serves a whole card.\n *\n * No `score` and no up/down vote. Jansathi's signed score exists to rank civic\n * content by community judgement, and a downvote arrow on a founder's first\n * post is a different product. Reactions and comments are the engagement axes\n * here. Adding a score later is additive; removing a visible downvote after\n * members have used it is not.\n */\nexport const feedPostSchema = z.object({\n id: objectIdSchema,\n contentKind: z.literal('post'),\n author: feedAuthorSchema,\n\n /**\n * The WHOLE body, never a server-side truncation.\n *\n * So there is no \"was this shortened\" flag and no second fetch to read the\n * rest: the card clamps visually and expands in place. At a 5,000 character\n * cap a page of twenty posts is small enough that trimming would cost a round\n * trip to save nothing. If that ever stops being true, a truncation flag is\n * an additive change; a client that has been guessing is not.\n */\n text: z.string().max(POST_MAX_BODY_CHARS),\n /** Ordered as the author attached them. */\n media: z.array(feedMediaSchema).max(POST_MAX_MEDIA),\n /** Present only when the body had an embeddable link. */\n linkPreview: linkPreviewSchema.optional(),\n /**\n * What this post is ABOUT, from the same vocabulary a member picks as their\n * interests. This is the whole mechanism behind a relevant feed: matching is\n * an intersection of two lists drawn from one enum, not a guess from who\n * wrote it.\n *\n * ⚠️ An EMPTY array is allowed and means the post is untagged, so interest\n * matching cannot reach it. That is a real consequence, not a nuance: an\n * untagged post is only ever found chronologically.\n */\n topics: z.array(topicSchema).max(MAX_TOPICS),\n\n commentCount: z.number().int().nonnegative(),\n reactionCounts: reactionCountsSchema,\n\n createdAt: z.iso.datetime(),\n /** Null when never edited. The edit window is the server's to enforce. */\n editedAt: z.iso.datetime().nullable(),\n\n viewer: viewerStateSchema.optional(),\n});\nexport type FeedPost = z.infer<typeof feedPostSchema>;\n\n// ─── Poll ────────────────────────────────────────────────────────────────────\n\n/**\n * One choice. `index` is the identifier a vote refers to, not the label:\n * sending indices avoids ambiguity between two options whose labels differ\n * only by case, spacing or an invisible character.\n */\nexport const pollOptionSchema = z.object({\n index: z\n .number()\n .int()\n .min(0)\n .max(POLL_MAX_OPTIONS - 1),\n label: z.string().min(1).max(POLL_OPTION_MAX_CHARS),\n voteCount: z.number().int().nonnegative(),\n});\nexport type PollOption = z.infer<typeof pollOptionSchema>;\n\n/**\n * A poll. Its own kind, not a post with extra fields, because votes are their\n * own records keyed by (poll, voter) and a post has nothing equivalent.\n *\n * Polls are not editable. Changing a question or an option after a vote has\n * been cast silently re-purposes that vote. Closing early is allowed; editing\n * is not.\n */\nexport const feedPollSchema = z.object({\n id: objectIdSchema,\n contentKind: z.literal('poll'),\n author: feedAuthorSchema,\n\n question: z.string().min(1).max(POLL_QUESTION_MAX_CHARS),\n options: z.array(pollOptionSchema).min(POLL_MIN_OPTIONS).max(POLL_MAX_OPTIONS),\n /** Unique voters, not votes: with multi-select the two differ. */\n totalVoters: z.number().int().nonnegative(),\n allowsMultipleChoices: z.boolean(),\n closesAt: z.iso.datetime(),\n /**\n * Whether the poll is closed, decided by the SERVER's clock.\n *\n * Derivable from `closesAt`, and deliberately sent anyway: a device with a\n * wrong clock would otherwise show a live poll as finished, or offer a vote\n * the server then rejects, and the member sees a control that lies. `closesAt`\n * stays for rendering \"closes in 2 days\"; this is what gates the control.\n */\n isClosed: z.boolean(),\n\n /** As on a post. A poll is feed content and is matched the same way. */\n topics: z.array(topicSchema).max(MAX_TOPICS),\n\n commentCount: z.number().int().nonnegative(),\n reactionCounts: reactionCountsSchema,\n\n createdAt: z.iso.datetime(),\n /** Always null. Kept so one byline component renders both kinds. */\n editedAt: z.iso.datetime().nullable(),\n\n viewer: viewerStateSchema\n .extend({\n /** Indices this viewer chose. Empty means not voted yet, and it is\n * always populated for the viewer: a voter can always see their own\n * answer even where others' results are hidden. */\n votedOptionIndices: z.array(z.number().int().nonnegative()),\n })\n .optional(),\n});\nexport type FeedPoll = z.infer<typeof feedPollSchema>;\n\n// ─── The feed item union ─────────────────────────────────────────────────────\n\n/**\n * Discriminated on `contentKind`, so a client narrows with a switch and the\n * compiler catches an unhandled kind the day one is added. A plain union would\n * need every field checked by hand and would silently accept a hybrid.\n */\nexport const feedItemSchema = z.discriminatedUnion('contentKind', [feedPostSchema, feedPollSchema]);\nexport type FeedItem = z.infer<typeof feedItemSchema>;\n\n// ─── Comments ────────────────────────────────────────────────────────────────\n\n/**\n * A comment, one level of nesting only: a reply to a comment is allowed, a\n * reply to a reply is not. Two levels stay readable on a phone; three do not,\n * and the server is what enforces it.\n */\nexport const feedCommentSchema = z.object({\n id: objectIdSchema,\n contentKind: contentKindSchema,\n contentId: objectIdSchema,\n /** Null for a top-level comment. A non-null value may only name a top-level\n * comment, never a reply. */\n parentCommentId: objectIdSchema.nullable(),\n author: feedAuthorSchema,\n\n text: z.string().max(COMMENT_MAX_BODY_CHARS),\n\n /** Meaningful only on a top-level comment; always 0 on a reply. */\n replyCount: z.number().int().nonnegative(),\n reactionCounts: reactionCountsSchema,\n\n createdAt: z.iso.datetime(),\n editedAt: z.iso.datetime().nullable(),\n\n /** No `bookmarked`: the post is the right thing to save, not a comment. */\n viewer: viewerStateSchema.omit({ bookmarked: true }).optional(),\n});\nexport type FeedComment = z.infer<typeof feedCommentSchema>;\n\n// ─── Queries ─────────────────────────────────────────────────────────────────\n\n/**\n * `GET /api/v1/feed`. Inherits `limit` and `cursor`, which already tolerate an\n * emptied control rather than answering 400.\n *\n * `interests=true` narrows to the viewer's topics (falling back to the plain\n * feed when they have none). `z.stringbool`, so `true`/`false`/`1`/`0` all\n * read as a person means them; until 0.2.0 the backend read this parameter\n * raw, outside the schema, and only the exact string `true` counted.\n *\n * ⚠ NO `authorId`. It was accepted here and then ignored by the server, so a\n * profile page asking for one founder's posts silently got everybody's. A\n * member's own posts are `GET /api/v1/feed/by-author/:userId`.\n */\nexport const feedQuerySchema = paginationQuerySchema.extend({\n sort: z.preprocess(emptyStringToUndefined, feedSortSchema.default('new')),\n interests: z.preprocess(emptyStringToUndefined, z.stringbool().default(false)),\n});\nexport type FeedQuery = z.infer<typeof feedQuerySchema>;\nexport type FeedQueryInput = z.input<typeof feedQuerySchema>;\n\n/** `GET /api/v1/feed/:contentKind/:contentId/comments`. */\nexport const commentsQuerySchema = paginationQuerySchema.extend({\n /** Omitted lists top-level comments; set, it lists that comment's replies. */\n parentCommentId: z.preprocess(emptyStringToUndefined, objectIdSchema.optional()),\n});\nexport type CommentsQuery = z.infer<typeof commentsQuerySchema>;\nexport type CommentsQueryInput = z.input<typeof commentsQuerySchema>;\n\n// ─── Route parameters ────────────────────────────────────────────────────────\n\n/**\n * Every id and kind in a feed URL, as a schema, so a bad one answers exactly\n * as a bad body field does: 400 `VALIDATION_ERROR`, the issue at\n * `location: 'params'`. Until 0.2.0 these were hand checks that answered\n * \"Invalid id.\" with no issues at all, and the comment routes never looked at\n * `:contentKind`.\n */\n\n/** `GET /feed/:id`, the permalink. */\nexport const feedItemParamsSchema = z.object({ id: objectIdSchema });\n\n/** `PATCH|DELETE /feed/:contentId`. */\nexport const contentParamsSchema = z.object({ contentId: objectIdSchema });\n\n/** A bookmark and a comment thread: a post or a poll. */\nexport const contentTargetParamsSchema = z.object({\n contentKind: contentKindSchema,\n contentId: objectIdSchema,\n});\n\n/** A reaction and a report: a post, a poll or a comment. */\nexport const engagementTargetParamsSchema = z.object({\n contentKind: engagementTargetKindSchema,\n contentId: objectIdSchema,\n});\n\n/** `POST|DELETE /feed/poll/:pollId/vote`. */\nexport const pollParamsSchema = z.object({ pollId: objectIdSchema });\n\n/** `DELETE /feed/comments/:commentId`. */\nexport const commentParamsSchema = z.object({ commentId: objectIdSchema });\n\n/** `GET /feed/by-author/:userId`. */\nexport const authorParamsSchema = z.object({ userId: objectIdSchema });\n\nexport type FeedItemParams = z.infer<typeof feedItemParamsSchema>;\nexport type ContentParams = z.infer<typeof contentParamsSchema>;\nexport type ContentTargetParams = z.infer<typeof contentTargetParamsSchema>;\nexport type EngagementTargetParams = z.infer<typeof engagementTargetParamsSchema>;\nexport type PollParams = z.infer<typeof pollParamsSchema>;\nexport type CommentParams = z.infer<typeof commentParamsSchema>;\nexport type AuthorParams = z.infer<typeof authorParamsSchema>;\n\n// ─── Pages ───────────────────────────────────────────────────────────────────\n\n/**\n * A page of the feed, and of one member's posts (`by-author`), which is the\n * same shape. `nextCursor` is null on the last page.\n */\nexport const feedPageSchema = paginatedSchema(feedItemSchema);\nexport type FeedPage = z.infer<typeof feedPageSchema>;\n\n/** A page of a thread: top-level comments, or one comment's replies. */\nexport const commentPageSchema = paginatedSchema(feedCommentSchema);\nexport type CommentPage = z.infer<typeof commentPageSchema>;\n\n// ─── Write bodies ────────────────────────────────────────────────────────────\n\n/**\n * ⚠️ Every write body below is its own object with NO defaults, and none of\n * them is built with `.pick().partial()`. `.partial()` does not switch off\n * `.default()`, so a patch body built that way rewrites defaulted fields on\n * every request. See `updateFounderProfileBodySchema` for the incident.\n */\n\n/**\n * An attachment on the way IN: a reference to something already uploaded, and\n * deliberately not a URL.\n *\n * Upload happens first, straight to storage, and the composer sends back the\n * key it was given (`uploadKeySchema`, in `uploads`). The server resolves that\n * key to the URL, dimensions and poster frame it reads from the object itself,\n * and those become the read shape.\n *\n * ⚠ The first draft of this took the full read shape, URL included. That is\n * the same hole that was closed on the profile: a URL in a write body is a\n * member-supplied URL, so a post could have carried `javascript:` in an image\n * source, and nothing downstream re-checks a value the server itself stored.\n * Taking a key instead removes the question rather than validating it, and it\n * also stops a member pointing a post at somebody else's file.\n *\n * `width`, `height` and `duration` are NOT accepted here either. They are\n * properties of the uploaded object, so the server reads them from the object;\n * taking them from the client would let a caller claim a 1x1 image and collapse\n * the card.\n */\nexport const attachedMediaSchema = z.object({\n uploadKey: uploadKeySchema,\n /** Author-supplied alt text, the one media field a member does write. */\n alt: z.string().trim().max(MEDIA_ALT_MAX).optional(),\n});\nexport type AttachedMedia = z.infer<typeof attachedMediaSchema>;\n\n/**\n * A post must carry something. Text alone, media alone, or both; empty is\n * refused here rather than reaching the feed as a blank card.\n */\nexport const createPostBodySchema = z\n .object({\n text: z.string().trim().max(POST_MAX_BODY_CHARS).optional(),\n /** Already uploaded. See `attachedMediaSchema`. Order is preserved. */\n media: z.array(attachedMediaSchema).max(POST_MAX_MEDIA).optional(),\n /** Absent means untagged, which means interest matching cannot find it. */\n topics: z.array(topicSchema).max(MAX_TOPICS).optional(),\n })\n .refine((b) => Boolean(b.text?.length) || Boolean(b.media?.length), {\n error: VALIDATION_MESSAGE.postEmpty,\n path: ['text'],\n });\nexport type CreatePostBody = z.infer<typeof createPostBodySchema>;\n\n/** Only the text is editable. Changing media after the fact makes every\n * earlier reply look like a reply to something else. */\nexport const updatePostBodySchema = z.object({\n text: z.string().trim().min(1).max(POST_MAX_BODY_CHARS),\n});\nexport type UpdatePostBody = z.infer<typeof updatePostBodySchema>;\n\nexport const createPollBodySchema = z\n .object({\n question: z.string().trim().min(1).max(POLL_QUESTION_MAX_CHARS),\n options: z\n .array(z.object({ label: z.string().trim().min(1).max(POLL_OPTION_MAX_CHARS) }))\n .min(POLL_MIN_OPTIONS)\n .max(POLL_MAX_OPTIONS),\n /**\n * Duration from now, NOT an absolute closing time. A client whose clock is\n * wrong would otherwise open a poll that is already closed, and the member\n * would have no way to tell why.\n */\n durationMs: z.number().int().min(POLL_MIN_DURATION_MS).max(POLL_MAX_DURATION_MS),\n allowsMultipleChoices: z.boolean(),\n topics: z.array(topicSchema).max(MAX_TOPICS).optional(),\n })\n .refine(\n (b) => {\n const seen = new Set<string>();\n for (const o of b.options) {\n const key = o.label.trim().toLowerCase();\n if (seen.has(key)) return false;\n seen.add(key);\n }\n return true;\n },\n { error: VALIDATION_MESSAGE.pollOptionsDuplicate, path: ['options'] },\n );\nexport type CreatePollBody = z.infer<typeof createPollBodySchema>;\n\n/** Cast or change a vote. The server upserts on (poll, voter), so voting\n * twice replaces rather than doubles. */\nexport const castVoteBodySchema = z.object({\n optionIndices: z\n .array(\n z\n .number()\n .int()\n .min(0)\n .max(POLL_MAX_OPTIONS - 1),\n )\n .min(1)\n .max(POLL_MAX_OPTIONS),\n});\nexport type CastVoteBody = z.infer<typeof castVoteBodySchema>;\n\nexport const createCommentBodySchema = z.object({\n text: z.string().trim().min(1).max(COMMENT_MAX_BODY_CHARS),\n /** Null for a top-level comment. */\n parentCommentId: objectIdSchema.nullable(),\n});\nexport type CreateCommentBody = z.infer<typeof createCommentBodySchema>;\n\nexport const updateCommentBodySchema = z.object({\n text: z.string().trim().min(1).max(COMMENT_MAX_BODY_CHARS),\n});\nexport type UpdateCommentBody = z.infer<typeof updateCommentBodySchema>;\n\n/** Set or clear a reaction. `null` clears it; at most one per viewer per item. */\nexport const setReactionBodySchema = z.object({\n reaction: reactionTypeSchema.nullable(),\n});\nexport type SetReactionBody = z.infer<typeof setReactionBodySchema>;\n\n// ─── Engagement responses ────────────────────────────────────────────────────\n\n/**\n * Returned by the reaction endpoint so the card can render the authoritative\n * result without a follow-up read. Without the fresh counts a client has to\n * guess the new total, and two quick taps leave it showing a number the server\n * never had.\n */\nexport const setReactionResponseSchema = z.object({\n contentKind: engagementTargetKindSchema,\n contentId: objectIdSchema,\n reaction: reactionTypeSchema.nullable(),\n reactionCounts: reactionCountsSchema,\n});\nexport type SetReactionResponse = z.infer<typeof setReactionResponseSchema>;\n\n// ─── Reporting ───────────────────────────────────────────────────────────────\n\n/**\n * Why a member reported something.\n *\n * ⚠️ Stored on every report row, so this is data, not a label: removing a value\n * orphans reports already filed under it, and the counts nobody can recover are\n * exactly the ones a moderation review needs. Append only.\n *\n * ⚠️ A REASON THAT IS MISSING DOES NOT DISAPPEAR, it lands in `other`. Whatever\n * is not named here becomes an undifferentiated pile that no aggregate can see\n * a pattern in, which is the one thing report categories exist for. Worth being\n * deliberate about the absences rather than discovering them later.\n */\nexport const REPORT_REASON_VALUES = [\n 'spam',\n 'harassment',\n 'misinformation',\n 'off_topic',\n 'impersonation',\n 'other',\n] as const;\nexport const reportReasonSchema = z.enum(REPORT_REASON_VALUES);\nexport type ReportReason = z.infer<typeof reportReasonSchema>;\n\n/**\n * What a member sends to report a post, poll or comment.\n *\n * The target is the URL's, not the body's: a report is filed against a specific\n * thing at a specific address, and taking the target from the body would let a\n * caller report one item while the route thinks it is another.\n *\n * `note` is free text and optional, which is the pressure valve for everything\n * the enum does not name. ⚠️ It is written by a member, so it is untrusted at\n * render: a moderation queue displays it, and a moderation queue is a screen\n * staff read quickly.\n */\nexport const createReportBodySchema = z.object({\n reason: reportReasonSchema,\n note: z.string().trim().max(REPORT_NOTE_MAX).optional(),\n});\nexport type CreateReportBody = z.infer<typeof createReportBodySchema>;\n\n/**\n * The answer to filing one.\n *\n * `alreadyReported` distinguishes \"we have taken this\" from \"you already told\n * us\", because one report per member per target means a second press is a\n * no-op, and a silent no-op reads as a failure to somebody who pressed twice\n * deliberately. It is NOT a count: how many OTHER people reported an item is\n * moderation's business and telling a reporter would turn the count into a\n * score anyone could farm.\n */\nexport const createReportResponseSchema = z.object({\n contentKind: engagementTargetKindSchema,\n contentId: objectIdSchema,\n alreadyReported: z.boolean(),\n});\nexport type CreateReportResponse = z.infer<typeof createReportResponseSchema>;\n\n/** The bookmark endpoint is a toggle and returns the state it settled on. */\nexport const toggleBookmarkResponseSchema = z.object({\n contentKind: contentKindSchema,\n contentId: objectIdSchema,\n bookmarked: z.boolean(),\n});\nexport type ToggleBookmarkResponse = z.infer<typeof toggleBookmarkResponseSchema>;\n\n/** A vote returns the whole poll: counts, totals and the viewer's own choices\n * all move together, and reassembling them client-side drifts. */\nexport const castVoteResponseSchema = z.object({\n poll: feedPollSchema,\n});\nexport type CastVoteResponse = z.infer<typeof castVoteResponseSchema>;\n"]}
1
+ {"version":3,"file":"feed.js","sourceRoot":"","sources":["../../../src/domains/feed/feed.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * founder-schema — Feed wire shapes: post, poll, comment, reaction\n *\n * ADAPTED FROM `jansathi-community-schema`, NOT DEPENDENT ON IT.\n *\n * The user ruled on 2026-09-21 that Founder Circle is a standalone app, so\n * these shapes are copied and cut down rather than imported. The reason is\n * worth keeping: `jansathi-community-schema` encodes Jansathi's reach levels,\n * community designations, official-area kinds and crowdfunding audiences.\n * Depending on it would put all of that permanently inside our contract for a\n * feed that has none of those concepts, and would mean a Jansathi publish can\n * break this product.\n *\n * DELIBERATELY NOT PORTED, so nobody \"restores\" them:\n * - the five civic verticals (lost_found, suggestions_complaints,\n * donate_item, crowdfunding, event). `contentKind` here is post | poll\n * only; comment threads and bookmarks key off it, and reactions and\n * reports off the same union plus `comment`.\n * - official communities, designations, identity verification badges.\n * - social score, and the whole area-proximity ranking vocabulary\n * (local / district / state / national / global).\n * - `areaLineage` as a REQUIRED authorship snapshot. Jansathi gates its\n * composer on a declared residence resolving to Indian district level or\n * deeper; the user ruled location is never mandatory here, so nothing in\n * this file requires one.\n * - anonymous posting, which in Jansathi is gated on identity verification\n * we do not have. A founders' network is an identified network.\n *\n * @module founder-schema/domains/feed\n */\n\nimport { z } from 'zod';\nimport {\n emptyStringToUndefined,\n objectIdSchema,\n paginationQuerySchema,\n safeUrlSchema,\n usernameSchema,\n} from '../../core/common.js';\nimport {\n LINK_PREVIEW_DESCRIPTION_MAX,\n LINK_PREVIEW_TITLE_MAX,\n MEDIA_ALT_MAX,\n REPORT_NOTE_MAX,\n} from '../../core/limits.js';\nimport { paginatedSchema } from '../../core/responses.js';\nimport { VALIDATION_MESSAGE } from '../../core/validation.js';\nimport { mediaImageSchema } from '../media/media.js';\nimport { topicSchema } from '../onboarding/onboarding.js';\n\n// ─── Limits ──────────────────────────────────────────────────────────────────\n\n/**\n * Exported because the composer must count down to the SAME number the server\n * rejects at. A client-side limit that disagrees with the server is a message\n * a member cannot send and cannot see why.\n */\nexport const POST_MAX_BODY_CHARS = 5000;\n/**\n * Total attachments on one post, images and videos together.\n *\n * ONE cap over ONE ordered list, rather than Jansathi's separate image and\n * video arrays. A member who posts a screenshot, then a clip, then another\n * screenshot means that order, and two parallel arrays cannot express it: the\n * card would show both images and then the video, which is not what was\n * written.\n */\nexport const POST_MAX_MEDIA = 10;\n/**\n * Topics per item.\n *\n * Capped low on purpose: a post tagged with everything is about nothing, and\n * one tagged with ten matches every member, which is how a relevance feature\n * turns back into a chronological one.\n */\nexport const MAX_TOPICS = 3;\nexport const COMMENT_MAX_BODY_CHARS = 2000;\n\nexport const POLL_QUESTION_MAX_CHARS = 280;\nexport const POLL_OPTION_MAX_CHARS = 80;\nexport const POLL_MIN_OPTIONS = 2;\nexport const POLL_MAX_OPTIONS = 6;\n/** 5 minutes. Below this a poll closes before anyone sees it. */\nexport const POLL_MIN_DURATION_MS = 5 * 60 * 1000;\n/** 30 days. */\nexport const POLL_MAX_DURATION_MS = 30 * 24 * 60 * 60 * 1000;\n\n/**\n * How long after writing it the author may still edit a post or a comment.\n *\n * A window rather than forever: an edit after people have reacted changes what\n * they reacted to. Deletion has no window, because a member must always be able\n * to remove their own words. The server is the gate (`EDIT_WINDOW_CLOSED`);\n * a client uses this to stop offering Edit at the moment the server would\n * start refusing it, instead of on the next reload.\n */\nexport const EDIT_WINDOW_MS = 15 * 60 * 1000;\n\n// ─── Enums ───────────────────────────────────────────────────────────────────\n\n/**\n * What can appear in the feed. Two kinds, not seven.\n *\n * Engagement is keyed by `(contentKind, contentId)` so one engagement bar\n * serves both, and comments hang off the same pair. Adding a kind later is\n * additive; the union is deliberately narrow now so the client never has to\n * handle a kind this product does not have.\n */\nexport const CONTENT_KIND_VALUES = ['post', 'poll'] as const;\nexport const contentKindSchema = z.enum(CONTENT_KIND_VALUES);\nexport type ContentKind = z.infer<typeof contentKindSchema>;\n\n/**\n * What a reaction or a report can point at: a feed item, or a comment on one.\n *\n * Wider than `ContentKind` on purpose. A comment carries reaction counts and a\n * viewer reaction, and a comment can be abusive, so both reach it. A bookmark\n * and a comment thread cannot: they take `ContentKind`. Until 0.2.0 the\n * reaction and report RESPONSES were typed `ContentKind` while the routes\n * accepted comments, so every answer about a comment broke this contract.\n */\nexport const ENGAGEMENT_TARGET_KIND_VALUES = [...CONTENT_KIND_VALUES, 'comment'] as const;\nexport const engagementTargetKindSchema = z.enum(ENGAGEMENT_TARGET_KIND_VALUES);\nexport type EngagementTargetKind = z.infer<typeof engagementTargetKindSchema>;\n\n/**\n * Reactions. Jansathi's `support` is dropped: it exists for reacting to a\n * civic complaint or a lost-and-found notice, which this feed does not carry.\n *\n * ⚠️ These values are stored per reaction row, so removing one orphans data.\n * Append only.\n */\nexport const REACTION_TYPE_VALUES = ['like', 'celebrate', 'insightful', 'curious'] as const;\nexport const reactionTypeSchema = z.enum(REACTION_TYPE_VALUES);\nexport type ReactionType = z.infer<typeof reactionTypeSchema>;\n\n/**\n * Feed ordering.\n *\n * `new` is the default here, unlike Jansathi where `hot` leads. A hot ranking\n * needs volume to be meaningful, and a brand-new network ranking a handful of\n * posts by a blended score mostly hides the newest thing somebody just wrote.\n * Revisit when there is enough content for `hot` to mean anything.\n */\nexport const FEED_SORT_VALUES = ['new', 'top'] as const;\nexport const feedSortSchema = z.enum(FEED_SORT_VALUES);\nexport type FeedSort = z.infer<typeof feedSortSchema>;\n\n/**\n * Counts keyed by reaction type, e.g. `{ like: 4, celebrate: 1 }`. Only the\n * reactions an item actually has need appear.\n *\n * ⚠️ `partialRecord`, NOT `record`. In Zod 4 an enum-keyed `z.record` is\n * EXHAUSTIVE: it demands every key, so `{ like: 4 }` fails with three missing\n * keys and `{}` fails with four. That would force the server to ship a zero for\n * every reaction on every item, and the day a fifth reaction is added every\n * previously stored payload and every older client stops parsing.\n * `partialRecord` keeps the key validation without the exhaustiveness, so an\n * unknown key like Jansathi's `support` is still refused.\n */\nexport const reactionCountsSchema = z.partialRecord(\n reactionTypeSchema,\n z.number().int().nonnegative(),\n);\nexport type ReactionCounts = z.infer<typeof reactionCountsSchema>;\n\n// ─── Author byline ───────────────────────────────────────────────────────────\n\n/**\n * The author as a card renders them.\n *\n * Every field here is LIVE, joined against the profile at read time rather\n * than frozen onto the post at write time. So a renamed handle, a new avatar\n * or an edited headline updates on old posts, and the byline link always\n * targets the current username instead of 404ing on a handle that moved.\n *\n * Jansathi additionally snapshots `localName` (\"where the author was when\n * posting\"). Not ported: location is optional here, so a byline that depends\n * on one would be blank for anyone who never set it.\n */\nexport const feedAuthorSchema = z.object({\n userId: z.string(),\n username: usernameSchema,\n displayName: z.string(),\n /** Server-generated or from the login provider, never member-typed, so a\n * plain URL check. Same reasoning as `avatarUrl` on the profile. */\n avatarUrl: z.url().optional(),\n /** The author's one-line headline, live-joined. Gives a founders' byline\n * the context a civic byline got from an area label. */\n headline: z.string().optional(),\n});\nexport type FeedAuthor = z.infer<typeof feedAuthorSchema>;\n\n// ─── Media ───────────────────────────────────────────────────────────────────\n\n/**\n * A post's image: a processed `mediaImageSchema` (every stored size, ready for\n * `srcset`, plus a placeholder) with the post's own alt text.\n *\n * `width` and `height` are required so a card can reserve the right box before\n * the image loads. Without them every image arrival shifts the feed under the\n * reader's thumb.\n *\n * `thumbnailUrl` is the smallest stored size, for a grid or a notification.\n *\n * URLs here come from our own upload pipeline, so they take a plain `z.url()`\n * rather than `httpUrlSchema`. ⚠️ If a \"paste an image link\" path is ever\n * added, these become member-supplied and must move to `httpUrlSchema`.\n */\nexport const feedImageSchema = mediaImageSchema.extend({\n kind: z.literal('image'),\n thumbnailUrl: z.url().optional(),\n /** Author-supplied alt text. Optional, and worth asking for. */\n alt: z.string().max(MEDIA_ALT_MAX).optional(),\n});\nexport type FeedImage = z.infer<typeof feedImageSchema>;\n\nexport const feedVideoSchema = z.object({\n kind: z.literal('video'),\n url: z.url(),\n /** Poster frame, server-extracted. Required: a video with no poster is a\n * black rectangle in the feed. */\n thumbnailUrl: z.url(),\n width: z.number().int().positive(),\n height: z.number().int().positive(),\n /** Seconds. */\n duration: z.number().positive(),\n});\nexport type FeedVideo = z.infer<typeof feedVideoSchema>;\n\n/**\n * One ordered list, discriminated on `kind`, so the carousel renders\n * attachments in the order they were attached and can switch on the kind\n * without inspecting fields.\n */\nexport const feedMediaSchema = z.discriminatedUnion('kind', [feedImageSchema, feedVideoSchema]);\nexport type FeedMedia = z.infer<typeof feedMediaSchema>;\n\n// ─── Link preview ────────────────────────────────────────────────────────────\n\n/**\n * Preview for the first link in a post body. Server-derived at write time and\n * stored, never re-fetched at render: a card that fetches a third-party page\n * per scroll leaks every reader's address to that site and blocks the feed on\n * somebody else's latency.\n *\n * Both URLs take `safeUrlSchema`: the scheme and host rules, WITHOUT the\n * typed-URL cap. `url` came out of a post body, which is already capped as a\n * whole, and `imageUrl` is whatever the remote page advertised; a long CDN URL\n * is ordinary, and one over `LINK_URL_MAX` would otherwise fail the whole feed\n * page it sits on. Neither is ours, so a consumer still treats both as\n * untrusted at render.\n */\nexport const linkPreviewSchema = z.object({\n url: safeUrlSchema,\n title: z.string().max(LINK_PREVIEW_TITLE_MAX).optional(),\n description: z.string().max(LINK_PREVIEW_DESCRIPTION_MAX).optional(),\n imageUrl: safeUrlSchema.optional(),\n});\nexport type LinkPreview = z.infer<typeof linkPreviewSchema>;\n\n// ─── Viewer state ────────────────────────────────────────────────────────────\n\n/**\n * What THIS viewer has done to this item, computed per request.\n *\n * Optional as a block, because an unauthenticated or server-rendered read has\n * no viewer. ⚠️ A client must treat an absent `viewer` as \"no state known\",\n * never as \"has not reacted\": rendering an empty reaction button off a missing\n * block is how a member sees their own reaction disappear on reload.\n */\nexport const viewerStateSchema = z.object({\n reaction: reactionTypeSchema.nullable(),\n bookmarked: z.boolean(),\n isAuthor: z.boolean(),\n\n /**\n * What this viewer may DO, decided by the server and sent as plain answers.\n *\n * Not role data for the client to reason about. The moment a client derives\n * \"can I edit this\" from roles and ownership, that derivation is a second\n * implementation of the rule, it drifts from the server's, and the drift is\n * invisible until the two disagree: either a button that 403s or a button\n * that should not have been there. `isAuthor` stays because the byline reads\n * differently for your own post, not because the client should compute rights\n * from it.\n *\n * `canEdit` is false once the edit window closes, so it is not simply\n * `isAuthor`.\n */\n canEdit: z.boolean(),\n canDelete: z.boolean(),\n canReport: z.boolean(),\n});\nexport type ViewerState = z.infer<typeof viewerStateSchema>;\n\n// ─── Post ────────────────────────────────────────────────────────────────────\n\n/**\n * A post as the feed and the single-post page receive it.\n *\n * Counts are denormalised onto the row so one document serves a whole card.\n *\n * No `score` and no up/down vote. Jansathi's signed score exists to rank civic\n * content by community judgement, and a downvote arrow on a founder's first\n * post is a different product. Reactions and comments are the engagement axes\n * here. Adding a score later is additive; removing a visible downvote after\n * members have used it is not.\n */\nexport const feedPostSchema = z.object({\n id: objectIdSchema,\n contentKind: z.literal('post'),\n author: feedAuthorSchema,\n\n /**\n * The WHOLE body, never a server-side truncation.\n *\n * So there is no \"was this shortened\" flag and no second fetch to read the\n * rest: the card clamps visually and expands in place. At a 5,000 character\n * cap a page of twenty posts is small enough that trimming would cost a round\n * trip to save nothing. If that ever stops being true, a truncation flag is\n * an additive change; a client that has been guessing is not.\n */\n text: z.string().max(POST_MAX_BODY_CHARS),\n /** Ordered as the author attached them. */\n media: z.array(feedMediaSchema).max(POST_MAX_MEDIA),\n /** Present only when the body had an embeddable link. */\n linkPreview: linkPreviewSchema.optional(),\n /**\n * What this post is ABOUT, from the same vocabulary a member picks as their\n * interests. This is the whole mechanism behind a relevant feed: matching is\n * an intersection of two lists drawn from one enum, not a guess from who\n * wrote it.\n *\n * ⚠️ An EMPTY array is allowed and means the post is untagged, so interest\n * matching cannot reach it. That is a real consequence, not a nuance: an\n * untagged post is only ever found chronologically.\n */\n topics: z.array(topicSchema).max(MAX_TOPICS),\n\n commentCount: z.number().int().nonnegative(),\n reactionCounts: reactionCountsSchema,\n\n createdAt: z.iso.datetime(),\n /** Null when never edited. The edit window is the server's to enforce. */\n editedAt: z.iso.datetime().nullable(),\n\n viewer: viewerStateSchema.optional(),\n});\nexport type FeedPost = z.infer<typeof feedPostSchema>;\n\n// ─── Poll ────────────────────────────────────────────────────────────────────\n\n/**\n * One choice. `index` is the identifier a vote refers to, not the label:\n * sending indices avoids ambiguity between two options whose labels differ\n * only by case, spacing or an invisible character.\n */\nexport const pollOptionSchema = z.object({\n index: z\n .number()\n .int()\n .min(0)\n .max(POLL_MAX_OPTIONS - 1),\n label: z.string().min(1).max(POLL_OPTION_MAX_CHARS),\n voteCount: z.number().int().nonnegative(),\n});\nexport type PollOption = z.infer<typeof pollOptionSchema>;\n\n/**\n * A poll. Its own kind, not a post with extra fields, because votes are their\n * own records keyed by (poll, voter) and a post has nothing equivalent.\n *\n * Polls are not editable. Changing a question or an option after a vote has\n * been cast silently re-purposes that vote. Closing early is allowed; editing\n * is not.\n */\nexport const feedPollSchema = z.object({\n id: objectIdSchema,\n contentKind: z.literal('poll'),\n author: feedAuthorSchema,\n\n question: z.string().min(1).max(POLL_QUESTION_MAX_CHARS),\n options: z.array(pollOptionSchema).min(POLL_MIN_OPTIONS).max(POLL_MAX_OPTIONS),\n /** Unique voters, not votes: with multi-select the two differ. */\n totalVoters: z.number().int().nonnegative(),\n allowsMultipleChoices: z.boolean(),\n closesAt: z.iso.datetime(),\n /**\n * Whether the poll is closed, decided by the SERVER's clock.\n *\n * Derivable from `closesAt`, and deliberately sent anyway: a device with a\n * wrong clock would otherwise show a live poll as finished, or offer a vote\n * the server then rejects, and the member sees a control that lies. `closesAt`\n * stays for rendering \"closes in 2 days\"; this is what gates the control.\n */\n isClosed: z.boolean(),\n\n /** As on a post. A poll is feed content and is matched the same way. */\n topics: z.array(topicSchema).max(MAX_TOPICS),\n\n commentCount: z.number().int().nonnegative(),\n reactionCounts: reactionCountsSchema,\n\n createdAt: z.iso.datetime(),\n /** Always null. Kept so one byline component renders both kinds. */\n editedAt: z.iso.datetime().nullable(),\n\n viewer: viewerStateSchema\n .extend({\n /** Indices this viewer chose. Empty means not voted yet, and it is\n * always populated for the viewer: a voter can always see their own\n * answer even where others' results are hidden. */\n votedOptionIndices: z.array(z.number().int().nonnegative()),\n })\n .optional(),\n});\nexport type FeedPoll = z.infer<typeof feedPollSchema>;\n\n// ─── The feed item union ─────────────────────────────────────────────────────\n\n/**\n * Discriminated on `contentKind`, so a client narrows with a switch and the\n * compiler catches an unhandled kind the day one is added. A plain union would\n * need every field checked by hand and would silently accept a hybrid.\n */\nexport const feedItemSchema = z.discriminatedUnion('contentKind', [feedPostSchema, feedPollSchema]);\nexport type FeedItem = z.infer<typeof feedItemSchema>;\n\n// ─── Comments ────────────────────────────────────────────────────────────────\n\n/**\n * A comment, one level of nesting only: a reply to a comment is allowed, a\n * reply to a reply is not. Two levels stay readable on a phone; three do not,\n * and the server is what enforces it.\n */\nexport const feedCommentSchema = z.object({\n id: objectIdSchema,\n contentKind: contentKindSchema,\n contentId: objectIdSchema,\n /** Null for a top-level comment. A non-null value may only name a top-level\n * comment, never a reply. */\n parentCommentId: objectIdSchema.nullable(),\n author: feedAuthorSchema,\n\n text: z.string().max(COMMENT_MAX_BODY_CHARS),\n\n /** Meaningful only on a top-level comment; always 0 on a reply. */\n replyCount: z.number().int().nonnegative(),\n reactionCounts: reactionCountsSchema,\n\n createdAt: z.iso.datetime(),\n editedAt: z.iso.datetime().nullable(),\n\n /** No `bookmarked`: the post is the right thing to save, not a comment. */\n viewer: viewerStateSchema.omit({ bookmarked: true }).optional(),\n});\nexport type FeedComment = z.infer<typeof feedCommentSchema>;\n\n// ─── Queries ─────────────────────────────────────────────────────────────────\n\n/**\n * `GET /api/v1/feed`. Inherits `limit` and `cursor`, which already tolerate an\n * emptied control rather than answering 400.\n *\n * `interests=true` narrows to the viewer's topics (falling back to the plain\n * feed when they have none). `z.stringbool`, so `true`/`false`/`1`/`0` all\n * read as a person means them; until 0.2.0 the backend read this parameter\n * raw, outside the schema, and only the exact string `true` counted.\n *\n * ⚠ NO `authorId`. It was accepted here and then ignored by the server, so a\n * profile page asking for one founder's posts silently got everybody's. A\n * member's own posts are `GET /api/v1/feed/by-author/:userId`.\n */\nexport const feedQuerySchema = paginationQuerySchema.extend({\n sort: z.preprocess(emptyStringToUndefined, feedSortSchema.default('new')),\n interests: z.preprocess(emptyStringToUndefined, z.stringbool().default(false)),\n});\nexport type FeedQuery = z.infer<typeof feedQuerySchema>;\nexport type FeedQueryInput = z.input<typeof feedQuerySchema>;\n\n/** `GET /api/v1/feed/:contentKind/:contentId/comments`. */\nexport const commentsQuerySchema = paginationQuerySchema.extend({\n /** Omitted lists top-level comments; set, it lists that comment's replies. */\n parentCommentId: z.preprocess(emptyStringToUndefined, objectIdSchema.optional()),\n});\nexport type CommentsQuery = z.infer<typeof commentsQuerySchema>;\nexport type CommentsQueryInput = z.input<typeof commentsQuerySchema>;\n\n// ─── Route parameters ────────────────────────────────────────────────────────\n\n/**\n * Every id and kind in a feed URL, as a schema, so a bad one answers exactly\n * as a bad body field does: 400 `VALIDATION_ERROR`, the issue at\n * `location: 'params'`. Until 0.2.0 these were hand checks that answered\n * \"Invalid id.\" with no issues at all, and the comment routes never looked at\n * `:contentKind`.\n */\n\n/** `GET /feed/:id`, the permalink. */\nexport const feedItemParamsSchema = z.object({ id: objectIdSchema });\n\n/** `PATCH|DELETE /feed/:contentId`. */\nexport const contentParamsSchema = z.object({ contentId: objectIdSchema });\n\n/** A bookmark and a comment thread: a post or a poll. */\nexport const contentTargetParamsSchema = z.object({\n contentKind: contentKindSchema,\n contentId: objectIdSchema,\n});\n\n/** A reaction and a report: a post, a poll or a comment. */\nexport const engagementTargetParamsSchema = z.object({\n contentKind: engagementTargetKindSchema,\n contentId: objectIdSchema,\n});\n\n/** `POST|DELETE /feed/poll/:pollId/vote`. */\nexport const pollParamsSchema = z.object({ pollId: objectIdSchema });\n\n/** `DELETE /feed/comments/:commentId`. */\nexport const commentParamsSchema = z.object({ commentId: objectIdSchema });\n\n/** `GET /feed/by-author/:userId`. */\nexport const authorParamsSchema = z.object({ userId: objectIdSchema });\n\nexport type FeedItemParams = z.infer<typeof feedItemParamsSchema>;\nexport type ContentParams = z.infer<typeof contentParamsSchema>;\nexport type ContentTargetParams = z.infer<typeof contentTargetParamsSchema>;\nexport type EngagementTargetParams = z.infer<typeof engagementTargetParamsSchema>;\nexport type PollParams = z.infer<typeof pollParamsSchema>;\nexport type CommentParams = z.infer<typeof commentParamsSchema>;\nexport type AuthorParams = z.infer<typeof authorParamsSchema>;\n\n// ─── Pages ───────────────────────────────────────────────────────────────────\n\n/**\n * A page of the feed, and of one member's posts (`by-author`), which is the\n * same shape. `nextCursor` is null on the last page.\n */\nexport const feedPageSchema = paginatedSchema(feedItemSchema);\nexport type FeedPage = z.infer<typeof feedPageSchema>;\n\n/** A page of a thread: top-level comments, or one comment's replies. */\nexport const commentPageSchema = paginatedSchema(feedCommentSchema);\nexport type CommentPage = z.infer<typeof commentPageSchema>;\n\n// ─── Write bodies ────────────────────────────────────────────────────────────\n\n/**\n * ⚠️ Every write body below is its own object with NO defaults, and none of\n * them is built with `.pick().partial()`. `.partial()` does not switch off\n * `.default()`, so a patch body built that way rewrites defaulted fields on\n * every request. See `updateFounderProfileBodySchema` for the incident.\n */\n\n/**\n * An attachment on the way IN: the id of an image already uploaded and\n * completed (`media`), and deliberately not a URL.\n *\n * ⚠ The first draft of this took the full read shape, URL included. That is\n * the same hole that was closed on the profile: a URL in a write body is a\n * member-supplied URL, so a post could have carried `javascript:` in an image\n * source, and nothing downstream re-checks a value the server itself stored.\n * Taking an id instead removes the question rather than validating it.\n *\n * `width`, `height` and the URLs are NOT accepted either: the server measured\n * them when it processed the upload, and taking them from the client would let\n * a caller claim a 1x1 image and collapse the card.\n *\n * The server attaches each id only if it is the caller's, processed, uploaded\n * for a post, and not attached anywhere yet (`UPLOAD_NOT_ATTACHABLE`\n * otherwise), so one upload can never appear on two posts.\n */\nexport const attachedMediaSchema = z.object({\n mediaId: objectIdSchema,\n /** Author-supplied alt text, the one media field a member does write. */\n alt: z.string().trim().max(MEDIA_ALT_MAX).optional(),\n});\nexport type AttachedMedia = z.infer<typeof attachedMediaSchema>;\n\n/**\n * A post must carry something. Text alone, media alone, or both; empty is\n * refused here rather than reaching the feed as a blank card.\n */\nexport const createPostBodySchema = z\n .object({\n text: z.string().trim().max(POST_MAX_BODY_CHARS).optional(),\n /** Already uploaded and completed. See `attachedMediaSchema`. Order is preserved. */\n media: z.array(attachedMediaSchema).max(POST_MAX_MEDIA).optional(),\n /** Absent means untagged, which means interest matching cannot find it. */\n topics: z.array(topicSchema).max(MAX_TOPICS).optional(),\n })\n .refine((b) => Boolean(b.text?.length) || Boolean(b.media?.length), {\n error: VALIDATION_MESSAGE.postEmpty,\n path: ['text'],\n })\n .refine((b) => new Set(b.media?.map((m) => m.mediaId)).size === (b.media?.length ?? 0), {\n error: VALIDATION_MESSAGE.mediaDuplicate,\n path: ['media'],\n });\nexport type CreatePostBody = z.infer<typeof createPostBodySchema>;\n\n/** Only the text is editable. Changing media after the fact makes every\n * earlier reply look like a reply to something else. */\nexport const updatePostBodySchema = z.object({\n text: z.string().trim().min(1).max(POST_MAX_BODY_CHARS),\n});\nexport type UpdatePostBody = z.infer<typeof updatePostBodySchema>;\n\nexport const createPollBodySchema = z\n .object({\n question: z.string().trim().min(1).max(POLL_QUESTION_MAX_CHARS),\n options: z\n .array(z.object({ label: z.string().trim().min(1).max(POLL_OPTION_MAX_CHARS) }))\n .min(POLL_MIN_OPTIONS)\n .max(POLL_MAX_OPTIONS),\n /**\n * Duration from now, NOT an absolute closing time. A client whose clock is\n * wrong would otherwise open a poll that is already closed, and the member\n * would have no way to tell why.\n */\n durationMs: z.number().int().min(POLL_MIN_DURATION_MS).max(POLL_MAX_DURATION_MS),\n allowsMultipleChoices: z.boolean(),\n topics: z.array(topicSchema).max(MAX_TOPICS).optional(),\n })\n .refine(\n (b) => {\n const seen = new Set<string>();\n for (const o of b.options) {\n const key = o.label.trim().toLowerCase();\n if (seen.has(key)) return false;\n seen.add(key);\n }\n return true;\n },\n { error: VALIDATION_MESSAGE.pollOptionsDuplicate, path: ['options'] },\n );\nexport type CreatePollBody = z.infer<typeof createPollBodySchema>;\n\n/** Cast or change a vote. The server upserts on (poll, voter), so voting\n * twice replaces rather than doubles. */\nexport const castVoteBodySchema = z.object({\n optionIndices: z\n .array(\n z\n .number()\n .int()\n .min(0)\n .max(POLL_MAX_OPTIONS - 1),\n )\n .min(1)\n .max(POLL_MAX_OPTIONS),\n});\nexport type CastVoteBody = z.infer<typeof castVoteBodySchema>;\n\nexport const createCommentBodySchema = z.object({\n text: z.string().trim().min(1).max(COMMENT_MAX_BODY_CHARS),\n /** Null for a top-level comment. */\n parentCommentId: objectIdSchema.nullable(),\n});\nexport type CreateCommentBody = z.infer<typeof createCommentBodySchema>;\n\nexport const updateCommentBodySchema = z.object({\n text: z.string().trim().min(1).max(COMMENT_MAX_BODY_CHARS),\n});\nexport type UpdateCommentBody = z.infer<typeof updateCommentBodySchema>;\n\n/** Set or clear a reaction. `null` clears it; at most one per viewer per item. */\nexport const setReactionBodySchema = z.object({\n reaction: reactionTypeSchema.nullable(),\n});\nexport type SetReactionBody = z.infer<typeof setReactionBodySchema>;\n\n// ─── Engagement responses ────────────────────────────────────────────────────\n\n/**\n * Returned by the reaction endpoint so the card can render the authoritative\n * result without a follow-up read. Without the fresh counts a client has to\n * guess the new total, and two quick taps leave it showing a number the server\n * never had.\n */\nexport const setReactionResponseSchema = z.object({\n contentKind: engagementTargetKindSchema,\n contentId: objectIdSchema,\n reaction: reactionTypeSchema.nullable(),\n reactionCounts: reactionCountsSchema,\n});\nexport type SetReactionResponse = z.infer<typeof setReactionResponseSchema>;\n\n// ─── Reporting ───────────────────────────────────────────────────────────────\n\n/**\n * Why a member reported something.\n *\n * ⚠️ Stored on every report row, so this is data, not a label: removing a value\n * orphans reports already filed under it, and the counts nobody can recover are\n * exactly the ones a moderation review needs. Append only.\n *\n * ⚠️ A REASON THAT IS MISSING DOES NOT DISAPPEAR, it lands in `other`. Whatever\n * is not named here becomes an undifferentiated pile that no aggregate can see\n * a pattern in, which is the one thing report categories exist for. Worth being\n * deliberate about the absences rather than discovering them later.\n */\nexport const REPORT_REASON_VALUES = [\n 'spam',\n 'harassment',\n 'misinformation',\n 'off_topic',\n 'impersonation',\n 'other',\n] as const;\nexport const reportReasonSchema = z.enum(REPORT_REASON_VALUES);\nexport type ReportReason = z.infer<typeof reportReasonSchema>;\n\n/**\n * What a member sends to report a post, poll or comment.\n *\n * The target is the URL's, not the body's: a report is filed against a specific\n * thing at a specific address, and taking the target from the body would let a\n * caller report one item while the route thinks it is another.\n *\n * `note` is free text and optional, which is the pressure valve for everything\n * the enum does not name. ⚠️ It is written by a member, so it is untrusted at\n * render: a moderation queue displays it, and a moderation queue is a screen\n * staff read quickly.\n */\nexport const createReportBodySchema = z.object({\n reason: reportReasonSchema,\n note: z.string().trim().max(REPORT_NOTE_MAX).optional(),\n});\nexport type CreateReportBody = z.infer<typeof createReportBodySchema>;\n\n/**\n * The answer to filing one.\n *\n * `alreadyReported` distinguishes \"we have taken this\" from \"you already told\n * us\", because one report per member per target means a second press is a\n * no-op, and a silent no-op reads as a failure to somebody who pressed twice\n * deliberately. It is NOT a count: how many OTHER people reported an item is\n * moderation's business and telling a reporter would turn the count into a\n * score anyone could farm.\n */\nexport const createReportResponseSchema = z.object({\n contentKind: engagementTargetKindSchema,\n contentId: objectIdSchema,\n alreadyReported: z.boolean(),\n});\nexport type CreateReportResponse = z.infer<typeof createReportResponseSchema>;\n\n/** The bookmark endpoint is a toggle and returns the state it settled on. */\nexport const toggleBookmarkResponseSchema = z.object({\n contentKind: contentKindSchema,\n contentId: objectIdSchema,\n bookmarked: z.boolean(),\n});\nexport type ToggleBookmarkResponse = z.infer<typeof toggleBookmarkResponseSchema>;\n\n/** A vote returns the whole poll: counts, totals and the viewer's own choices\n * all move together, and reassembling them client-side drifts. */\nexport const castVoteResponseSchema = z.object({\n poll: feedPollSchema,\n});\nexport type CastVoteResponse = z.infer<typeof castVoteResponseSchema>;\n"]}