founder-schema 0.4.0 → 0.6.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/README.md +64 -1
- package/dist/access/permissions.d.ts +24 -2
- package/dist/access/permissions.d.ts.map +1 -1
- package/dist/access/permissions.js +9 -0
- package/dist/access/permissions.js.map +1 -1
- package/dist/core/common.d.ts +37 -0
- package/dist/core/common.d.ts.map +1 -1
- package/dist/core/common.js +49 -0
- package/dist/core/common.js.map +1 -1
- package/dist/core/errors.d.ts +41 -1
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +75 -4
- package/dist/core/errors.js.map +1 -1
- package/dist/core/limits.d.ts +76 -4
- package/dist/core/limits.d.ts.map +1 -1
- package/dist/core/limits.js +80 -4
- package/dist/core/limits.js.map +1 -1
- package/dist/core/responses.d.ts +55 -0
- package/dist/core/responses.d.ts.map +1 -1
- package/dist/core/responses.js +3 -1
- package/dist/core/responses.js.map +1 -1
- package/dist/core/validation.d.ts +16 -0
- package/dist/core/validation.d.ts.map +1 -1
- package/dist/core/validation.js +16 -0
- package/dist/core/validation.js.map +1 -1
- package/dist/domains/account/account.d.ts +2 -0
- package/dist/domains/account/account.d.ts.map +1 -1
- package/dist/domains/account/account.js +2 -0
- package/dist/domains/account/account.js.map +1 -1
- package/dist/domains/audit/audit.d.ts +4 -0
- package/dist/domains/audit/audit.d.ts.map +1 -1
- package/dist/domains/audit/audit.js +6 -3
- package/dist/domains/audit/audit.js.map +1 -1
- package/dist/domains/contributions/contributions.d.ts +158 -0
- package/dist/domains/contributions/contributions.d.ts.map +1 -0
- package/dist/domains/contributions/contributions.js +93 -0
- package/dist/domains/contributions/contributions.js.map +1 -0
- package/dist/domains/discover/discover.d.ts +5 -0
- package/dist/domains/discover/discover.d.ts.map +1 -1
- package/dist/domains/discover/discover.js +3 -13
- package/dist/domains/discover/discover.js.map +1 -1
- package/dist/domains/feed/feed.d.ts +293 -32
- package/dist/domains/feed/feed.d.ts.map +1 -1
- package/dist/domains/feed/feed.js +109 -38
- package/dist/domains/feed/feed.js.map +1 -1
- package/dist/domains/media/media.d.ts +159 -0
- package/dist/domains/media/media.d.ts.map +1 -0
- package/dist/domains/media/media.js +136 -0
- package/dist/domains/media/media.js.map +1 -0
- package/dist/domains/members/members.d.ts +5 -5
- package/dist/domains/moderation/moderation.d.ts +53 -3
- package/dist/domains/moderation/moderation.d.ts.map +1 -1
- package/dist/domains/moderation/moderation.js +47 -8
- package/dist/domains/moderation/moderation.js.map +1 -1
- package/dist/domains/notifications/notifications.d.ts +212 -0
- package/dist/domains/notifications/notifications.d.ts.map +1 -0
- package/dist/domains/notifications/notifications.js +144 -0
- package/dist/domains/notifications/notifications.js.map +1 -0
- package/dist/domains/onboarding/onboarding.d.ts +21 -24
- package/dist/domains/onboarding/onboarding.d.ts.map +1 -1
- package/dist/domains/onboarding/onboarding.js +21 -20
- package/dist/domains/onboarding/onboarding.js.map +1 -1
- package/dist/domains/products/products.d.ts +1449 -0
- package/dist/domains/products/products.d.ts.map +1 -0
- package/dist/domains/products/products.js +453 -0
- package/dist/domains/products/products.js.map +1 -0
- package/dist/domains/profile/founder-profile.d.ts +66 -0
- package/dist/domains/profile/founder-profile.d.ts.map +1 -1
- package/dist/domains/profile/founder-profile.js +70 -1
- package/dist/domains/profile/founder-profile.js.map +1 -1
- package/dist/domains/reviews/reviews.d.ts +184 -0
- package/dist/domains/reviews/reviews.d.ts.map +1 -0
- package/dist/domains/reviews/reviews.js +99 -0
- package/dist/domains/reviews/reviews.js.map +1 -0
- package/dist/domains/uploads/uploads.d.ts +54 -64
- package/dist/domains/uploads/uploads.d.ts.map +1 -1
- package/dist/domains/uploads/uploads.js +43 -60
- package/dist/domains/uploads/uploads.js.map +1 -1
- package/dist/index.d.ts +11 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -7
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* founder-schema — Media: what an uploaded image becomes
|
|
3
|
+
*
|
|
4
|
+
* THE PIPELINE, end to end (0.5.0):
|
|
5
|
+
*
|
|
6
|
+
* 1. The client shrinks the photo (long edge at most the largest size in
|
|
7
|
+
* `MEDIA_SIZES`, WebP or JPEG) and asks for an upload slot:
|
|
8
|
+
* `POST /api/v1/uploads/presign` with a purpose, a content type and the
|
|
9
|
+
* exact byte count (`uploadPresignBodySchema`).
|
|
10
|
+
* 2. It PUTs the bytes straight to storage, with the headers it was given.
|
|
11
|
+
* Nothing passes through the API server.
|
|
12
|
+
* 3. It calls `POST /api/v1/uploads/:mediaId/complete`. The server checks the
|
|
13
|
+
* file, decodes it fully, applies the photo's rotation, REMOVES EVERY
|
|
14
|
+
* PIECE OF METADATA (GPS position, camera, timestamps), re-encodes it as
|
|
15
|
+
* WebP at each size in `MEDIA_SIZES`, deletes the raw upload, and answers
|
|
16
|
+
* with `uploadedMediaSchema`.
|
|
17
|
+
* 4. A post (`attachedMediaSchema`), the avatar (`setAvatarBodySchema`,
|
|
18
|
+
* onboarding's `avatarMediaId`), or a product's logo and screenshots
|
|
19
|
+
* (`setProductLogoBodySchema`, `setProductScreenshotsBodySchema`, 0.6.0)
|
|
20
|
+
* takes the media id. One upload is used once, by its owner, for the
|
|
21
|
+
* purpose it was uploaded for.
|
|
22
|
+
*
|
|
23
|
+
* A product's images belong to the PRODUCT, not to whoever on the team uploaded
|
|
24
|
+
* them: they stay when that person leaves or deletes their account, and go when
|
|
25
|
+
* the product is deleted.
|
|
26
|
+
*
|
|
27
|
+
* What nobody sees: the raw upload. It sits in a folder the CDN cannot reach
|
|
28
|
+
* and is deleted when processing finishes, or by storage itself after a day.
|
|
29
|
+
* Only the re-encoded sizes are ever served.
|
|
30
|
+
*
|
|
31
|
+
* An image never attached is deleted after `MEDIA_UNATTACHED_TTL_HOURS`; a
|
|
32
|
+
* deleted post's images, a replaced avatar, and an erased account's images are
|
|
33
|
+
* deleted from storage by the server, so a copied link stops working.
|
|
34
|
+
*
|
|
35
|
+
* @module founder-schema/domains/media
|
|
36
|
+
*/
|
|
37
|
+
import { z } from 'zod';
|
|
38
|
+
/**
|
|
39
|
+
* What an upload is for. It decides the sizes made and where it may be attached.
|
|
40
|
+
* Append only: every stored upload names one.
|
|
41
|
+
*/
|
|
42
|
+
export declare const MEDIA_PURPOSE_VALUES: readonly ["post", "avatar", "product_logo", "product_screenshot"];
|
|
43
|
+
export declare const mediaPurposeSchema: z.ZodEnum<{
|
|
44
|
+
post: "post";
|
|
45
|
+
avatar: "avatar";
|
|
46
|
+
product_logo: "product_logo";
|
|
47
|
+
product_screenshot: "product_screenshot";
|
|
48
|
+
}>;
|
|
49
|
+
export type MediaPurpose = z.infer<typeof mediaPurposeSchema>;
|
|
50
|
+
/**
|
|
51
|
+
* The content types a member may upload. Every one is decoded and re-encoded
|
|
52
|
+
* to WebP before anyone sees it, so this list is about what the server can
|
|
53
|
+
* DECODE safely, not what it serves.
|
|
54
|
+
*
|
|
55
|
+
* Not HEIC: an iPhone's photo picker converts HEIC to JPEG for the web, and a
|
|
56
|
+
* client that shrinks the photo first re-encodes it anyway. SVG never: it is a
|
|
57
|
+
* document that can carry script, not an image.
|
|
58
|
+
*/
|
|
59
|
+
export declare const MEDIA_UPLOAD_CONTENT_TYPES: readonly ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
|
60
|
+
export declare const mediaUploadContentTypeSchema: z.ZodEnum<{
|
|
61
|
+
"image/jpeg": "image/jpeg";
|
|
62
|
+
"image/png": "image/png";
|
|
63
|
+
"image/webp": "image/webp";
|
|
64
|
+
"image/gif": "image/gif";
|
|
65
|
+
}>;
|
|
66
|
+
export type MediaUploadContentType = z.infer<typeof mediaUploadContentTypeSchema>;
|
|
67
|
+
/**
|
|
68
|
+
* The sizes each purpose is stored at, smallest first.
|
|
69
|
+
*
|
|
70
|
+
* - post: the LONG edge, aspect kept. 540 for a feed card on a plain screen,
|
|
71
|
+
* 1080 for the same card on a phone's dense screen, 2048 for the full-screen
|
|
72
|
+
* view. An image smaller than a size is never enlarged, so a small image
|
|
73
|
+
* has fewer sizes.
|
|
74
|
+
* - avatar: a SQUARE, cropped around the most interesting region (usually a
|
|
75
|
+
* face). 192 for every small circle (bylines, lists, chat) up to 64 CSS
|
|
76
|
+
* pixels on a 3x screen, 512 for a profile header.
|
|
77
|
+
* - product_logo: a SQUARE, but NEVER CROPPED: the whole logo is fitted
|
|
78
|
+
* inside it and the rest is transparent, because cropping a wordmark the
|
|
79
|
+
* way an avatar is cropped cuts letters off. Transparency is kept. 96 for
|
|
80
|
+
* the product chip on a post (32 CSS pixels at 3x), 256 for a Discover card,
|
|
81
|
+
* 512 for the product page.
|
|
82
|
+
* - product_screenshot: as a post's image (long edge, aspect kept); a phone
|
|
83
|
+
* screenshot is tall, a desktop one wide. An animated GIF (a short demo)
|
|
84
|
+
* stays animated, within the same limits as a post's.
|
|
85
|
+
*
|
|
86
|
+
* A client shrinks a photo to the largest size here before uploading: anything
|
|
87
|
+
* bigger is thrown away by the server anyway, and costs the member data.
|
|
88
|
+
*/
|
|
89
|
+
export declare const MEDIA_SIZES: {
|
|
90
|
+
readonly post: readonly [540, 1080, 2048];
|
|
91
|
+
readonly avatar: readonly [192, 512];
|
|
92
|
+
readonly product_logo: readonly [96, 256, 512];
|
|
93
|
+
readonly product_screenshot: readonly [540, 1080, 2048];
|
|
94
|
+
};
|
|
95
|
+
/** The most sizes any purpose stores: the bound on `variants`. */
|
|
96
|
+
export declare const MEDIA_VARIANTS_MAX: number;
|
|
97
|
+
/** One stored size of an image. */
|
|
98
|
+
export declare const mediaVariantSchema: z.ZodObject<{
|
|
99
|
+
width: z.ZodNumber;
|
|
100
|
+
height: z.ZodNumber;
|
|
101
|
+
url: z.ZodURL;
|
|
102
|
+
}, z.core.$strip>;
|
|
103
|
+
export type MediaVariant = z.infer<typeof mediaVariantSchema>;
|
|
104
|
+
/**
|
|
105
|
+
* A processed image, as every read shows it.
|
|
106
|
+
*
|
|
107
|
+
* `variants` is a ready-made `srcset`: smallest first, each with its own pixel
|
|
108
|
+
* width. `url`, `width` and `height` repeat the largest one, so a client that
|
|
109
|
+
* ignores `variants` still renders correctly and can reserve the image's box
|
|
110
|
+
* from the aspect ratio before it loads (no layout shift).
|
|
111
|
+
*
|
|
112
|
+
* `placeholder` is a ThumbHash (base64): a ~30 character blurred preview a
|
|
113
|
+
* client can paint instantly while the image loads.
|
|
114
|
+
*
|
|
115
|
+
* URLs point at our media CDN. They come from our own pipeline, never from a
|
|
116
|
+
* member, so a plain `z.url()`.
|
|
117
|
+
*/
|
|
118
|
+
export declare const mediaImageSchema: z.ZodObject<{
|
|
119
|
+
id: z.ZodString;
|
|
120
|
+
url: z.ZodURL;
|
|
121
|
+
width: z.ZodNumber;
|
|
122
|
+
height: z.ZodNumber;
|
|
123
|
+
variants: z.ZodArray<z.ZodObject<{
|
|
124
|
+
width: z.ZodNumber;
|
|
125
|
+
height: z.ZodNumber;
|
|
126
|
+
url: z.ZodURL;
|
|
127
|
+
}, z.core.$strip>>;
|
|
128
|
+
placeholder: z.ZodOptional<z.ZodString>;
|
|
129
|
+
}, z.core.$strip>;
|
|
130
|
+
export type MediaImage = z.infer<typeof mediaImageSchema>;
|
|
131
|
+
/**
|
|
132
|
+
* `POST /api/v1/uploads/:mediaId/complete` answers with this: the image as it
|
|
133
|
+
* will appear, not yet attached to anything.
|
|
134
|
+
*
|
|
135
|
+
* `expiresAt` is when the server deletes it if it is still unattached
|
|
136
|
+
* (`MEDIA_UNATTACHED_TTL_HOURS` after the slot was issued). A composer left
|
|
137
|
+
* open longer than that must upload again.
|
|
138
|
+
*/
|
|
139
|
+
export declare const uploadedMediaSchema: z.ZodObject<{
|
|
140
|
+
id: z.ZodString;
|
|
141
|
+
url: z.ZodURL;
|
|
142
|
+
width: z.ZodNumber;
|
|
143
|
+
height: z.ZodNumber;
|
|
144
|
+
variants: z.ZodArray<z.ZodObject<{
|
|
145
|
+
width: z.ZodNumber;
|
|
146
|
+
height: z.ZodNumber;
|
|
147
|
+
url: z.ZodURL;
|
|
148
|
+
}, z.core.$strip>>;
|
|
149
|
+
placeholder: z.ZodOptional<z.ZodString>;
|
|
150
|
+
purpose: z.ZodEnum<{
|
|
151
|
+
post: "post";
|
|
152
|
+
avatar: "avatar";
|
|
153
|
+
product_logo: "product_logo";
|
|
154
|
+
product_screenshot: "product_screenshot";
|
|
155
|
+
}>;
|
|
156
|
+
expiresAt: z.ZodISODateTime;
|
|
157
|
+
}, z.core.$strip>;
|
|
158
|
+
export type UploadedMedia = z.infer<typeof uploadedMediaSchema>;
|
|
159
|
+
//# sourceMappingURL=media.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media.d.ts","sourceRoot":"","sources":["../../../src/domains/media/media.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB;;;GAGG;AACH,eAAO,MAAM,oBAAoB,mEAKvB,CAAC;AACX,eAAO,MAAM,kBAAkB;;;;;EAA+B,CAAC;AAC/D,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D;;;;;;;;GAQG;AACH,eAAO,MAAM,0BAA0B,iEAK7B,CAAC;AACX,eAAO,MAAM,4BAA4B;;;;;EAAqC,CAAC;AAC/E,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAC;AAElF;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,WAAW;;;;;CAKoC,CAAC;AAE7D,kEAAkE;AAClE,eAAO,MAAM,kBAAkB,QAE9B,CAAC;AAEF,mCAAmC;AACnC,eAAO,MAAM,kBAAkB;;;;iBAI7B,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB;;;;;;;;;;;iBAO3B,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE1D;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;iBAG9B,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC"}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* founder-schema — Media: what an uploaded image becomes
|
|
3
|
+
*
|
|
4
|
+
* THE PIPELINE, end to end (0.5.0):
|
|
5
|
+
*
|
|
6
|
+
* 1. The client shrinks the photo (long edge at most the largest size in
|
|
7
|
+
* `MEDIA_SIZES`, WebP or JPEG) and asks for an upload slot:
|
|
8
|
+
* `POST /api/v1/uploads/presign` with a purpose, a content type and the
|
|
9
|
+
* exact byte count (`uploadPresignBodySchema`).
|
|
10
|
+
* 2. It PUTs the bytes straight to storage, with the headers it was given.
|
|
11
|
+
* Nothing passes through the API server.
|
|
12
|
+
* 3. It calls `POST /api/v1/uploads/:mediaId/complete`. The server checks the
|
|
13
|
+
* file, decodes it fully, applies the photo's rotation, REMOVES EVERY
|
|
14
|
+
* PIECE OF METADATA (GPS position, camera, timestamps), re-encodes it as
|
|
15
|
+
* WebP at each size in `MEDIA_SIZES`, deletes the raw upload, and answers
|
|
16
|
+
* with `uploadedMediaSchema`.
|
|
17
|
+
* 4. A post (`attachedMediaSchema`), the avatar (`setAvatarBodySchema`,
|
|
18
|
+
* onboarding's `avatarMediaId`), or a product's logo and screenshots
|
|
19
|
+
* (`setProductLogoBodySchema`, `setProductScreenshotsBodySchema`, 0.6.0)
|
|
20
|
+
* takes the media id. One upload is used once, by its owner, for the
|
|
21
|
+
* purpose it was uploaded for.
|
|
22
|
+
*
|
|
23
|
+
* A product's images belong to the PRODUCT, not to whoever on the team uploaded
|
|
24
|
+
* them: they stay when that person leaves or deletes their account, and go when
|
|
25
|
+
* the product is deleted.
|
|
26
|
+
*
|
|
27
|
+
* What nobody sees: the raw upload. It sits in a folder the CDN cannot reach
|
|
28
|
+
* and is deleted when processing finishes, or by storage itself after a day.
|
|
29
|
+
* Only the re-encoded sizes are ever served.
|
|
30
|
+
*
|
|
31
|
+
* An image never attached is deleted after `MEDIA_UNATTACHED_TTL_HOURS`; a
|
|
32
|
+
* deleted post's images, a replaced avatar, and an erased account's images are
|
|
33
|
+
* deleted from storage by the server, so a copied link stops working.
|
|
34
|
+
*
|
|
35
|
+
* @module founder-schema/domains/media
|
|
36
|
+
*/
|
|
37
|
+
import { z } from 'zod';
|
|
38
|
+
import { MEDIA_PLACEHOLDER_MAX } from '../../core/limits.js';
|
|
39
|
+
/**
|
|
40
|
+
* What an upload is for. It decides the sizes made and where it may be attached.
|
|
41
|
+
* Append only: every stored upload names one.
|
|
42
|
+
*/
|
|
43
|
+
export const MEDIA_PURPOSE_VALUES = [
|
|
44
|
+
'post',
|
|
45
|
+
'avatar',
|
|
46
|
+
'product_logo',
|
|
47
|
+
'product_screenshot',
|
|
48
|
+
];
|
|
49
|
+
export const mediaPurposeSchema = z.enum(MEDIA_PURPOSE_VALUES);
|
|
50
|
+
/**
|
|
51
|
+
* The content types a member may upload. Every one is decoded and re-encoded
|
|
52
|
+
* to WebP before anyone sees it, so this list is about what the server can
|
|
53
|
+
* DECODE safely, not what it serves.
|
|
54
|
+
*
|
|
55
|
+
* Not HEIC: an iPhone's photo picker converts HEIC to JPEG for the web, and a
|
|
56
|
+
* client that shrinks the photo first re-encodes it anyway. SVG never: it is a
|
|
57
|
+
* document that can carry script, not an image.
|
|
58
|
+
*/
|
|
59
|
+
export const MEDIA_UPLOAD_CONTENT_TYPES = [
|
|
60
|
+
'image/jpeg',
|
|
61
|
+
'image/png',
|
|
62
|
+
'image/webp',
|
|
63
|
+
'image/gif',
|
|
64
|
+
];
|
|
65
|
+
export const mediaUploadContentTypeSchema = z.enum(MEDIA_UPLOAD_CONTENT_TYPES);
|
|
66
|
+
/**
|
|
67
|
+
* The sizes each purpose is stored at, smallest first.
|
|
68
|
+
*
|
|
69
|
+
* - post: the LONG edge, aspect kept. 540 for a feed card on a plain screen,
|
|
70
|
+
* 1080 for the same card on a phone's dense screen, 2048 for the full-screen
|
|
71
|
+
* view. An image smaller than a size is never enlarged, so a small image
|
|
72
|
+
* has fewer sizes.
|
|
73
|
+
* - avatar: a SQUARE, cropped around the most interesting region (usually a
|
|
74
|
+
* face). 192 for every small circle (bylines, lists, chat) up to 64 CSS
|
|
75
|
+
* pixels on a 3x screen, 512 for a profile header.
|
|
76
|
+
* - product_logo: a SQUARE, but NEVER CROPPED: the whole logo is fitted
|
|
77
|
+
* inside it and the rest is transparent, because cropping a wordmark the
|
|
78
|
+
* way an avatar is cropped cuts letters off. Transparency is kept. 96 for
|
|
79
|
+
* the product chip on a post (32 CSS pixels at 3x), 256 for a Discover card,
|
|
80
|
+
* 512 for the product page.
|
|
81
|
+
* - product_screenshot: as a post's image (long edge, aspect kept); a phone
|
|
82
|
+
* screenshot is tall, a desktop one wide. An animated GIF (a short demo)
|
|
83
|
+
* stays animated, within the same limits as a post's.
|
|
84
|
+
*
|
|
85
|
+
* A client shrinks a photo to the largest size here before uploading: anything
|
|
86
|
+
* bigger is thrown away by the server anyway, and costs the member data.
|
|
87
|
+
*/
|
|
88
|
+
export const MEDIA_SIZES = {
|
|
89
|
+
post: [540, 1080, 2048],
|
|
90
|
+
avatar: [192, 512],
|
|
91
|
+
product_logo: [96, 256, 512],
|
|
92
|
+
product_screenshot: [540, 1080, 2048],
|
|
93
|
+
};
|
|
94
|
+
/** The most sizes any purpose stores: the bound on `variants`. */
|
|
95
|
+
export const MEDIA_VARIANTS_MAX = Math.max(...Object.values(MEDIA_SIZES).map((sizes) => sizes.length));
|
|
96
|
+
/** One stored size of an image. */
|
|
97
|
+
export const mediaVariantSchema = z.object({
|
|
98
|
+
width: z.number().int().positive(),
|
|
99
|
+
height: z.number().int().positive(),
|
|
100
|
+
url: z.url(),
|
|
101
|
+
});
|
|
102
|
+
/**
|
|
103
|
+
* A processed image, as every read shows it.
|
|
104
|
+
*
|
|
105
|
+
* `variants` is a ready-made `srcset`: smallest first, each with its own pixel
|
|
106
|
+
* width. `url`, `width` and `height` repeat the largest one, so a client that
|
|
107
|
+
* ignores `variants` still renders correctly and can reserve the image's box
|
|
108
|
+
* from the aspect ratio before it loads (no layout shift).
|
|
109
|
+
*
|
|
110
|
+
* `placeholder` is a ThumbHash (base64): a ~30 character blurred preview a
|
|
111
|
+
* client can paint instantly while the image loads.
|
|
112
|
+
*
|
|
113
|
+
* URLs point at our media CDN. They come from our own pipeline, never from a
|
|
114
|
+
* member, so a plain `z.url()`.
|
|
115
|
+
*/
|
|
116
|
+
export const mediaImageSchema = z.object({
|
|
117
|
+
id: z.string(),
|
|
118
|
+
url: z.url(),
|
|
119
|
+
width: z.number().int().positive(),
|
|
120
|
+
height: z.number().int().positive(),
|
|
121
|
+
variants: z.array(mediaVariantSchema).min(1).max(MEDIA_VARIANTS_MAX),
|
|
122
|
+
placeholder: z.string().max(MEDIA_PLACEHOLDER_MAX).optional(),
|
|
123
|
+
});
|
|
124
|
+
/**
|
|
125
|
+
* `POST /api/v1/uploads/:mediaId/complete` answers with this: the image as it
|
|
126
|
+
* will appear, not yet attached to anything.
|
|
127
|
+
*
|
|
128
|
+
* `expiresAt` is when the server deletes it if it is still unattached
|
|
129
|
+
* (`MEDIA_UNATTACHED_TTL_HOURS` after the slot was issued). A composer left
|
|
130
|
+
* open longer than that must upload again.
|
|
131
|
+
*/
|
|
132
|
+
export const uploadedMediaSchema = mediaImageSchema.extend({
|
|
133
|
+
purpose: mediaPurposeSchema,
|
|
134
|
+
expiresAt: z.iso.datetime(),
|
|
135
|
+
});
|
|
136
|
+
//# sourceMappingURL=media.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media.js","sourceRoot":"","sources":["../../../src/domains/media/media.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAE7D;;;GAGG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG;IAClC,MAAM;IACN,QAAQ;IACR,cAAc;IACd,oBAAoB;CACZ,CAAC;AACX,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAC;AAG/D;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG;IACxC,YAAY;IACZ,WAAW;IACX,YAAY;IACZ,WAAW;CACH,CAAC;AACX,MAAM,CAAC,MAAM,4BAA4B,GAAG,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAC;AAG/E;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,IAAI,EAAE,CAAC,GAAG,EAAE,IAAI,EAAE,IAAI,CAAC;IACvB,MAAM,EAAE,CAAC,GAAG,EAAE,GAAG,CAAC;IAClB,YAAY,EAAE,CAAC,EAAE,EAAE,GAAG,EAAE,GAAG,CAAC;IAC5B,kBAAkB,EAAE,CAAC,GAAG,EAAE,IAAI,EAAE,IAAI,CAAC;CACqB,CAAC;AAE7D,kEAAkE;AAClE,MAAM,CAAC,MAAM,kBAAkB,GAAG,IAAI,CAAC,GAAG,CACxC,GAAG,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,CAC3D,CAAC;AAEF,mCAAmC;AACnC,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IACzC,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE;IAClC,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE;IACnC,GAAG,EAAE,CAAC,CAAC,GAAG,EAAE;CACb,CAAC,CAAC;AAGH;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC;IACvC,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE;IACd,GAAG,EAAE,CAAC,CAAC,GAAG,EAAE;IACZ,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE;IAClC,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE;IACnC,QAAQ,EAAE,CAAC,CAAC,KAAK,CAAC,kBAAkB,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,kBAAkB,CAAC;IACpE,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC,QAAQ,EAAE;CAC9D,CAAC,CAAC;AAGH;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,gBAAgB,CAAC,MAAM,CAAC;IACzD,OAAO,EAAE,kBAAkB;IAC3B,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,QAAQ,EAAE;CAC5B,CAAC,CAAC","sourcesContent":["/**\n * founder-schema — Media: what an uploaded image becomes\n *\n * THE PIPELINE, end to end (0.5.0):\n *\n * 1. The client shrinks the photo (long edge at most the largest size in\n * `MEDIA_SIZES`, WebP or JPEG) and asks for an upload slot:\n * `POST /api/v1/uploads/presign` with a purpose, a content type and the\n * exact byte count (`uploadPresignBodySchema`).\n * 2. It PUTs the bytes straight to storage, with the headers it was given.\n * Nothing passes through the API server.\n * 3. It calls `POST /api/v1/uploads/:mediaId/complete`. The server checks the\n * file, decodes it fully, applies the photo's rotation, REMOVES EVERY\n * PIECE OF METADATA (GPS position, camera, timestamps), re-encodes it as\n * WebP at each size in `MEDIA_SIZES`, deletes the raw upload, and answers\n * with `uploadedMediaSchema`.\n * 4. A post (`attachedMediaSchema`), the avatar (`setAvatarBodySchema`,\n * onboarding's `avatarMediaId`), or a product's logo and screenshots\n * (`setProductLogoBodySchema`, `setProductScreenshotsBodySchema`, 0.6.0)\n * takes the media id. One upload is used once, by its owner, for the\n * purpose it was uploaded for.\n *\n * A product's images belong to the PRODUCT, not to whoever on the team uploaded\n * them: they stay when that person leaves or deletes their account, and go when\n * the product is deleted.\n *\n * What nobody sees: the raw upload. It sits in a folder the CDN cannot reach\n * and is deleted when processing finishes, or by storage itself after a day.\n * Only the re-encoded sizes are ever served.\n *\n * An image never attached is deleted after `MEDIA_UNATTACHED_TTL_HOURS`; a\n * deleted post's images, a replaced avatar, and an erased account's images are\n * deleted from storage by the server, so a copied link stops working.\n *\n * @module founder-schema/domains/media\n */\n\nimport { z } from 'zod';\nimport { MEDIA_PLACEHOLDER_MAX } from '../../core/limits.js';\n\n/**\n * What an upload is for. It decides the sizes made and where it may be attached.\n * Append only: every stored upload names one.\n */\nexport const MEDIA_PURPOSE_VALUES = [\n 'post',\n 'avatar',\n 'product_logo',\n 'product_screenshot',\n] as const;\nexport const mediaPurposeSchema = z.enum(MEDIA_PURPOSE_VALUES);\nexport type MediaPurpose = z.infer<typeof mediaPurposeSchema>;\n\n/**\n * The content types a member may upload. Every one is decoded and re-encoded\n * to WebP before anyone sees it, so this list is about what the server can\n * DECODE safely, not what it serves.\n *\n * Not HEIC: an iPhone's photo picker converts HEIC to JPEG for the web, and a\n * client that shrinks the photo first re-encodes it anyway. SVG never: it is a\n * document that can carry script, not an image.\n */\nexport const MEDIA_UPLOAD_CONTENT_TYPES = [\n 'image/jpeg',\n 'image/png',\n 'image/webp',\n 'image/gif',\n] as const;\nexport const mediaUploadContentTypeSchema = z.enum(MEDIA_UPLOAD_CONTENT_TYPES);\nexport type MediaUploadContentType = z.infer<typeof mediaUploadContentTypeSchema>;\n\n/**\n * The sizes each purpose is stored at, smallest first.\n *\n * - post: the LONG edge, aspect kept. 540 for a feed card on a plain screen,\n * 1080 for the same card on a phone's dense screen, 2048 for the full-screen\n * view. An image smaller than a size is never enlarged, so a small image\n * has fewer sizes.\n * - avatar: a SQUARE, cropped around the most interesting region (usually a\n * face). 192 for every small circle (bylines, lists, chat) up to 64 CSS\n * pixels on a 3x screen, 512 for a profile header.\n * - product_logo: a SQUARE, but NEVER CROPPED: the whole logo is fitted\n * inside it and the rest is transparent, because cropping a wordmark the\n * way an avatar is cropped cuts letters off. Transparency is kept. 96 for\n * the product chip on a post (32 CSS pixels at 3x), 256 for a Discover card,\n * 512 for the product page.\n * - product_screenshot: as a post's image (long edge, aspect kept); a phone\n * screenshot is tall, a desktop one wide. An animated GIF (a short demo)\n * stays animated, within the same limits as a post's.\n *\n * A client shrinks a photo to the largest size here before uploading: anything\n * bigger is thrown away by the server anyway, and costs the member data.\n */\nexport const MEDIA_SIZES = {\n post: [540, 1080, 2048],\n avatar: [192, 512],\n product_logo: [96, 256, 512],\n product_screenshot: [540, 1080, 2048],\n} as const satisfies Record<MediaPurpose, readonly number[]>;\n\n/** The most sizes any purpose stores: the bound on `variants`. */\nexport const MEDIA_VARIANTS_MAX = Math.max(\n ...Object.values(MEDIA_SIZES).map((sizes) => sizes.length),\n);\n\n/** One stored size of an image. */\nexport const mediaVariantSchema = z.object({\n width: z.number().int().positive(),\n height: z.number().int().positive(),\n url: z.url(),\n});\nexport type MediaVariant = z.infer<typeof mediaVariantSchema>;\n\n/**\n * A processed image, as every read shows it.\n *\n * `variants` is a ready-made `srcset`: smallest first, each with its own pixel\n * width. `url`, `width` and `height` repeat the largest one, so a client that\n * ignores `variants` still renders correctly and can reserve the image's box\n * from the aspect ratio before it loads (no layout shift).\n *\n * `placeholder` is a ThumbHash (base64): a ~30 character blurred preview a\n * client can paint instantly while the image loads.\n *\n * URLs point at our media CDN. They come from our own pipeline, never from a\n * member, so a plain `z.url()`.\n */\nexport const mediaImageSchema = z.object({\n id: z.string(),\n url: z.url(),\n width: z.number().int().positive(),\n height: z.number().int().positive(),\n variants: z.array(mediaVariantSchema).min(1).max(MEDIA_VARIANTS_MAX),\n placeholder: z.string().max(MEDIA_PLACEHOLDER_MAX).optional(),\n});\nexport type MediaImage = z.infer<typeof mediaImageSchema>;\n\n/**\n * `POST /api/v1/uploads/:mediaId/complete` answers with this: the image as it\n * will appear, not yet attached to anything.\n *\n * `expiresAt` is when the server deletes it if it is still unattached\n * (`MEDIA_UNATTACHED_TTL_HOURS` after the slot was issued). A composer left\n * open longer than that must upload again.\n */\nexport const uploadedMediaSchema = mediaImageSchema.extend({\n purpose: mediaPurposeSchema,\n expiresAt: z.iso.datetime(),\n});\nexport type UploadedMedia = z.infer<typeof uploadedMediaSchema>;\n"]}
|
|
@@ -33,8 +33,8 @@ import { z } from 'zod';
|
|
|
33
33
|
/** What staff can see an account's state as. A deleted account is not listed. */
|
|
34
34
|
export declare const MEMBER_STATUS_VALUES: readonly ["active", "suspended"];
|
|
35
35
|
export declare const memberStatusSchema: z.ZodEnum<{
|
|
36
|
-
suspended: "suspended";
|
|
37
36
|
active: "active";
|
|
37
|
+
suspended: "suspended";
|
|
38
38
|
}>;
|
|
39
39
|
export type MemberStatus = z.infer<typeof memberStatusSchema>;
|
|
40
40
|
/** `:memberId` in the paths above. */
|
|
@@ -52,8 +52,8 @@ export declare const memberDirectoryQuerySchema: z.ZodObject<{
|
|
|
52
52
|
cursor: z.ZodOptional<z.ZodString>;
|
|
53
53
|
q: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
|
|
54
54
|
status: z.ZodPreprocess<z.ZodOptional<z.ZodEnum<{
|
|
55
|
-
suspended: "suspended";
|
|
56
55
|
active: "active";
|
|
56
|
+
suspended: "suspended";
|
|
57
57
|
}>>, unknown>;
|
|
58
58
|
role: z.ZodPreprocess<z.ZodOptional<z.ZodEnum<{
|
|
59
59
|
superadmin: "superadmin";
|
|
@@ -72,8 +72,8 @@ export declare const memberSummarySchema: z.ZodObject<{
|
|
|
72
72
|
avatarUrl: z.ZodNullable<z.ZodURL>;
|
|
73
73
|
email: z.ZodNullable<z.ZodString>;
|
|
74
74
|
status: z.ZodEnum<{
|
|
75
|
-
suspended: "suspended";
|
|
76
75
|
active: "active";
|
|
76
|
+
suspended: "suspended";
|
|
77
77
|
}>;
|
|
78
78
|
roles: z.ZodArray<z.ZodEnum<{
|
|
79
79
|
superadmin: "superadmin";
|
|
@@ -93,8 +93,8 @@ export declare const memberDirectoryPageSchema: z.ZodObject<{
|
|
|
93
93
|
avatarUrl: z.ZodNullable<z.ZodURL>;
|
|
94
94
|
email: z.ZodNullable<z.ZodString>;
|
|
95
95
|
status: z.ZodEnum<{
|
|
96
|
-
suspended: "suspended";
|
|
97
96
|
active: "active";
|
|
97
|
+
suspended: "suspended";
|
|
98
98
|
}>;
|
|
99
99
|
roles: z.ZodArray<z.ZodEnum<{
|
|
100
100
|
superadmin: "superadmin";
|
|
@@ -126,8 +126,8 @@ export declare const memberDetailSchema: z.ZodObject<{
|
|
|
126
126
|
avatarUrl: z.ZodNullable<z.ZodURL>;
|
|
127
127
|
email: z.ZodNullable<z.ZodString>;
|
|
128
128
|
status: z.ZodEnum<{
|
|
129
|
-
suspended: "suspended";
|
|
130
129
|
active: "active";
|
|
130
|
+
suspended: "suspended";
|
|
131
131
|
}>;
|
|
132
132
|
roles: z.ZodArray<z.ZodEnum<{
|
|
133
133
|
superadmin: "superadmin";
|
|
@@ -11,7 +11,14 @@
|
|
|
11
11
|
* what its author deleted) leaves the reports open and writes nothing: for
|
|
12
12
|
* those, dismiss.
|
|
13
13
|
* POST /api/v1/moderation/:contentKind/:contentId/decision { action, note? }
|
|
14
|
-
* decide on one post, poll or
|
|
14
|
+
* decide on one post, poll, comment, product or review; answers the
|
|
15
|
+
* item as it now is
|
|
16
|
+
*
|
|
17
|
+
* PRODUCTS AND REVIEWS (0.6.0) are reported and decided here too. A product is
|
|
18
|
+
* hidden or restored, never removed by staff (it is its team's work; hiding
|
|
19
|
+
* takes it out of Discover and off every page but its team's). A review is
|
|
20
|
+
* hidden, restored or removed like a comment. The hidden and removed lists
|
|
21
|
+
* hold them too, so a hidden product can be found and put back.
|
|
15
22
|
*
|
|
16
23
|
* ONE ITEM PER THING, NOT PER REPORT. Five members reporting one post is one
|
|
17
24
|
* row with five reports, so a moderator decides once. The queue's order is
|
|
@@ -32,7 +39,18 @@
|
|
|
32
39
|
* @module founder-schema/domains/moderation
|
|
33
40
|
*/
|
|
34
41
|
import { z } from 'zod';
|
|
35
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Where something is read in the app: a post or poll, or a product page (for a
|
|
44
|
+
* product, and for a review of it).
|
|
45
|
+
*/
|
|
46
|
+
export declare const MODERATION_CONTEXT_KIND_VALUES: readonly ["post", "poll", "product"];
|
|
47
|
+
export declare const moderationContextKindSchema: z.ZodEnum<{
|
|
48
|
+
product: "product";
|
|
49
|
+
post: "post";
|
|
50
|
+
poll: "poll";
|
|
51
|
+
}>;
|
|
52
|
+
export type ModerationContextKind = z.infer<typeof moderationContextKindSchema>;
|
|
53
|
+
/** Where a post, poll, comment, product or review stands. */
|
|
36
54
|
export declare const MODERATION_STATE_VALUES: readonly ["visible", "hidden", "removed"];
|
|
37
55
|
export declare const moderationStateSchema: z.ZodEnum<{
|
|
38
56
|
visible: "visible";
|
|
@@ -48,18 +66,35 @@ export declare const moderationActionSchema: z.ZodEnum<{
|
|
|
48
66
|
remove: "remove";
|
|
49
67
|
}>;
|
|
50
68
|
export type ModerationAction = z.infer<typeof moderationActionSchema>;
|
|
51
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* Which decisions apply to which kind of thing: everything, except that a
|
|
71
|
+
* product is never removed by staff (it is its team's work; hiding it takes it
|
|
72
|
+
* out of Discover and off every page but its team's). The decision dialog
|
|
73
|
+
* offers these, and the server refuses anything else with
|
|
74
|
+
* `MODERATION_ACTION_INVALID`. Exhaustive by type.
|
|
75
|
+
*/
|
|
76
|
+
export declare const MODERATION_ACTIONS_BY_TARGET_KIND: {
|
|
77
|
+
readonly post: readonly ["dismiss", "hide", "restore", "remove"];
|
|
78
|
+
readonly poll: readonly ["dismiss", "hide", "restore", "remove"];
|
|
79
|
+
readonly comment: readonly ["dismiss", "hide", "restore", "remove"];
|
|
80
|
+
readonly review: readonly ["dismiss", "hide", "restore", "remove"];
|
|
81
|
+
readonly product: readonly ["dismiss", "hide", "restore"];
|
|
82
|
+
};
|
|
83
|
+
/** One post, poll, comment, product or review as moderation sees it. */
|
|
52
84
|
export declare const moderationItemSchema: z.ZodObject<{
|
|
53
85
|
target: z.ZodObject<{
|
|
54
86
|
kind: z.ZodEnum<{
|
|
87
|
+
product: "product";
|
|
55
88
|
post: "post";
|
|
56
89
|
poll: "poll";
|
|
57
90
|
comment: "comment";
|
|
91
|
+
review: "review";
|
|
58
92
|
}>;
|
|
59
93
|
id: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
60
94
|
}, z.core.$strip>;
|
|
61
95
|
content: z.ZodObject<{
|
|
62
96
|
kind: z.ZodEnum<{
|
|
97
|
+
product: "product";
|
|
63
98
|
post: "post";
|
|
64
99
|
poll: "poll";
|
|
65
100
|
}>;
|
|
@@ -106,14 +141,17 @@ export declare const moderationPageSchema: z.ZodObject<{
|
|
|
106
141
|
items: z.ZodArray<z.ZodObject<{
|
|
107
142
|
target: z.ZodObject<{
|
|
108
143
|
kind: z.ZodEnum<{
|
|
144
|
+
product: "product";
|
|
109
145
|
post: "post";
|
|
110
146
|
poll: "poll";
|
|
111
147
|
comment: "comment";
|
|
148
|
+
review: "review";
|
|
112
149
|
}>;
|
|
113
150
|
id: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
114
151
|
}, z.core.$strip>;
|
|
115
152
|
content: z.ZodObject<{
|
|
116
153
|
kind: z.ZodEnum<{
|
|
154
|
+
product: "product";
|
|
117
155
|
post: "post";
|
|
118
156
|
poll: "poll";
|
|
119
157
|
}>;
|
|
@@ -170,6 +208,18 @@ export declare const moderationContentQuerySchema: z.ZodObject<{
|
|
|
170
208
|
}, z.core.$strip>;
|
|
171
209
|
export type ModerationContentQuery = z.infer<typeof moderationContentQuerySchema>;
|
|
172
210
|
export type ModerationContentQueryInput = z.input<typeof moderationContentQuerySchema>;
|
|
211
|
+
/** `POST /moderation/:contentKind/:contentId/decision`: what is decided on. */
|
|
212
|
+
export declare const moderationTargetParamsSchema: z.ZodObject<{
|
|
213
|
+
contentKind: z.ZodEnum<{
|
|
214
|
+
product: "product";
|
|
215
|
+
post: "post";
|
|
216
|
+
poll: "poll";
|
|
217
|
+
comment: "comment";
|
|
218
|
+
review: "review";
|
|
219
|
+
}>;
|
|
220
|
+
contentId: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
221
|
+
}, z.core.$strip>;
|
|
222
|
+
export type ModerationTargetParams = z.infer<typeof moderationTargetParamsSchema>;
|
|
173
223
|
/** `POST /moderation/:contentKind/:contentId/decision`. */
|
|
174
224
|
export declare const moderationDecisionBodySchema: z.ZodObject<{
|
|
175
225
|
action: z.ZodEnum<{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"moderation.d.ts","sourceRoot":"","sources":["../../../src/domains/moderation/moderation.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"moderation.d.ts","sourceRoot":"","sources":["../../../src/domains/moderation/moderation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAWxB;;;GAGG;AACH,eAAO,MAAM,8BAA8B,sCAAuC,CAAC;AACnF,eAAO,MAAM,2BAA2B;;;;EAAyC,CAAC;AAClF,MAAM,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,2BAA2B,CAAC,CAAC;AAEhF,6DAA6D;AAC7D,eAAO,MAAM,uBAAuB,2CAA4C,CAAC;AACjF,eAAO,MAAM,qBAAqB;;;;EAAkC,CAAC;AACrE,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAEpE,eAAO,MAAM,wBAAwB,mDAAoD,CAAC;AAC1F,eAAO,MAAM,sBAAsB;;;;;EAAmC,CAAC;AACvE,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE;;;;;;GAMG;AACH,eAAO,MAAM,iCAAiC;;;;;;CAM4B,CAAC;AAI3E,wEAAwE;AACxE,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAuC/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAElE,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAAwC,CAAC;AAC1E,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAElE,wDAAwD;AACxD,eAAO,MAAM,4BAA4B;;;;;;;;iBAEvC,CAAC;AACH,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAC;AAClF,MAAM,MAAM,2BAA2B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAC;AAEvF,+EAA+E;AAC/E,eAAO,MAAM,4BAA4B;;;;;;;;;iBAGvC,CAAC;AACH,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAC;AAElF,2DAA2D;AAC3D,eAAO,MAAM,4BAA4B;;;;;;;;iBAIvC,CAAC;AACH,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAC"}
|
|
@@ -11,7 +11,14 @@
|
|
|
11
11
|
* what its author deleted) leaves the reports open and writes nothing: for
|
|
12
12
|
* those, dismiss.
|
|
13
13
|
* POST /api/v1/moderation/:contentKind/:contentId/decision { action, note? }
|
|
14
|
-
* decide on one post, poll or
|
|
14
|
+
* decide on one post, poll, comment, product or review; answers the
|
|
15
|
+
* item as it now is
|
|
16
|
+
*
|
|
17
|
+
* PRODUCTS AND REVIEWS (0.6.0) are reported and decided here too. A product is
|
|
18
|
+
* hidden or restored, never removed by staff (it is its team's work; hiding
|
|
19
|
+
* takes it out of Discover and off every page but its team's). A review is
|
|
20
|
+
* hidden, restored or removed like a comment. The hidden and removed lists
|
|
21
|
+
* hold them too, so a hidden product can be found and put back.
|
|
15
22
|
*
|
|
16
23
|
* ONE ITEM PER THING, NOT PER REPORT. Five members reporting one post is one
|
|
17
24
|
* row with five reports, so a moderator decides once. The queue's order is
|
|
@@ -35,20 +42,47 @@ import { z } from 'zod';
|
|
|
35
42
|
import { emptyStringToUndefined, objectIdSchema, optionalTrimmedString, paginationQuerySchema, } from '../../core/common.js';
|
|
36
43
|
import { MODERATION_NOTE_MAX } from '../../core/limits.js';
|
|
37
44
|
import { paginatedSchema } from '../../core/responses.js';
|
|
38
|
-
import {
|
|
39
|
-
/**
|
|
45
|
+
import { reportReasonSchema, reportTargetKindSchema } from '../feed/feed.js';
|
|
46
|
+
/**
|
|
47
|
+
* Where something is read in the app: a post or poll, or a product page (for a
|
|
48
|
+
* product, and for a review of it).
|
|
49
|
+
*/
|
|
50
|
+
export const MODERATION_CONTEXT_KIND_VALUES = ['post', 'poll', 'product'];
|
|
51
|
+
export const moderationContextKindSchema = z.enum(MODERATION_CONTEXT_KIND_VALUES);
|
|
52
|
+
/** Where a post, poll, comment, product or review stands. */
|
|
40
53
|
export const MODERATION_STATE_VALUES = ['visible', 'hidden', 'removed'];
|
|
41
54
|
export const moderationStateSchema = z.enum(MODERATION_STATE_VALUES);
|
|
42
55
|
export const MODERATION_ACTION_VALUES = ['dismiss', 'hide', 'restore', 'remove'];
|
|
43
56
|
export const moderationActionSchema = z.enum(MODERATION_ACTION_VALUES);
|
|
57
|
+
/**
|
|
58
|
+
* Which decisions apply to which kind of thing: everything, except that a
|
|
59
|
+
* product is never removed by staff (it is its team's work; hiding it takes it
|
|
60
|
+
* out of Discover and off every page but its team's). The decision dialog
|
|
61
|
+
* offers these, and the server refuses anything else with
|
|
62
|
+
* `MODERATION_ACTION_INVALID`. Exhaustive by type.
|
|
63
|
+
*/
|
|
64
|
+
export const MODERATION_ACTIONS_BY_TARGET_KIND = {
|
|
65
|
+
post: MODERATION_ACTION_VALUES,
|
|
66
|
+
poll: MODERATION_ACTION_VALUES,
|
|
67
|
+
comment: MODERATION_ACTION_VALUES,
|
|
68
|
+
review: MODERATION_ACTION_VALUES,
|
|
69
|
+
product: ['dismiss', 'hide', 'restore'],
|
|
70
|
+
};
|
|
44
71
|
const count = z.number().int().min(0);
|
|
45
|
-
/** One post, poll or
|
|
72
|
+
/** One post, poll, comment, product or review as moderation sees it. */
|
|
46
73
|
export const moderationItemSchema = z.object({
|
|
47
|
-
target: z.object({ kind:
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
74
|
+
target: z.object({ kind: reportTargetKindSchema, id: objectIdSchema }),
|
|
75
|
+
/**
|
|
76
|
+
* Where it is read in the app: the post or poll itself, or the one a comment
|
|
77
|
+
* is on; the product page for a product or a review.
|
|
78
|
+
*/
|
|
79
|
+
content: z.object({ kind: moderationContextKindSchema, id: objectIdSchema }),
|
|
80
|
+
/**
|
|
81
|
+
* Its first words, cut to `MODERATION_EXCERPT_MAX`: a poll's question, a
|
|
82
|
+
* product's name and tagline, a review's stars and text.
|
|
83
|
+
*/
|
|
51
84
|
excerpt: z.string(),
|
|
85
|
+
/** Who wrote it; for a product, its owner. */
|
|
52
86
|
author: z.object({
|
|
53
87
|
id: objectIdSchema,
|
|
54
88
|
displayName: z.string(),
|
|
@@ -81,6 +115,11 @@ export const moderationPageSchema = paginatedSchema(moderationItemSchema);
|
|
|
81
115
|
export const moderationContentQuerySchema = paginationQuerySchema.extend({
|
|
82
116
|
state: z.preprocess(emptyStringToUndefined, moderationStateSchema.default('visible')),
|
|
83
117
|
});
|
|
118
|
+
/** `POST /moderation/:contentKind/:contentId/decision`: what is decided on. */
|
|
119
|
+
export const moderationTargetParamsSchema = z.object({
|
|
120
|
+
contentKind: reportTargetKindSchema,
|
|
121
|
+
contentId: objectIdSchema,
|
|
122
|
+
});
|
|
84
123
|
/** `POST /moderation/:contentKind/:contentId/decision`. */
|
|
85
124
|
export const moderationDecisionBodySchema = z.object({
|
|
86
125
|
action: moderationActionSchema,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"moderation.js","sourceRoot":"","sources":["../../../src/domains/moderation/moderation.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"moderation.js","sourceRoot":"","sources":["../../../src/domains/moderation/moderation.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * founder-schema: moderation\n *\n * GET /api/v1/moderation/queue ?limit&cursor\n * what members reported and nobody has decided on, oldest report first\n * GET /api/v1/moderation/content ?state&limit&cursor\n * by state, newest first: visible is posts and polls by when posted;\n * hidden and removed also hold comments (so a hidden comment can be\n * found and put back), by when hidden or removed\n * A decision that changes nothing (hiding what is already hidden, removing\n * what its author deleted) leaves the reports open and writes nothing: for\n * those, dismiss.\n * POST /api/v1/moderation/:contentKind/:contentId/decision { action, note? }\n * decide on one post, poll, comment, product or review; answers the\n * item as it now is\n *\n * PRODUCTS AND REVIEWS (0.6.0) are reported and decided here too. A product is\n * hidden or restored, never removed by staff (it is its team's work; hiding\n * takes it out of Discover and off every page but its team's). A review is\n * hidden, restored or removed like a comment. The hidden and removed lists\n * hold them too, so a hidden product can be found and put back.\n *\n * ONE ITEM PER THING, NOT PER REPORT. Five members reporting one post is one\n * row with five reports, so a moderator decides once. The queue's order is\n * the oldest open report first: whoever has waited longest is seen first, and\n * a pile-on cannot jump the line.\n *\n * THE DECISIONS\n * dismiss it stays up; its open reports close as dismissed\n * hide out of every feed and thread, kept, restorable; reports actioned\n * restore a hidden item goes back up\n * remove deleted as its author deleting it would (counts move), not\n * restorable from here; reports actioned\n * Every decision on someone else's content is written to the audit log, with\n * the note when there is one.\n *\n * A report's `note` was written by a member: untrusted text, shown as text.\n *\n * @module founder-schema/domains/moderation\n */\n\nimport { z } from 'zod';\nimport {\n emptyStringToUndefined,\n objectIdSchema,\n optionalTrimmedString,\n paginationQuerySchema,\n} from '../../core/common.js';\nimport { MODERATION_NOTE_MAX } from '../../core/limits.js';\nimport { paginatedSchema } from '../../core/responses.js';\nimport { type ReportTargetKind, reportReasonSchema, reportTargetKindSchema } from '../feed/feed.js';\n\n/**\n * Where something is read in the app: a post or poll, or a product page (for a\n * product, and for a review of it).\n */\nexport const MODERATION_CONTEXT_KIND_VALUES = ['post', 'poll', 'product'] as const;\nexport const moderationContextKindSchema = z.enum(MODERATION_CONTEXT_KIND_VALUES);\nexport type ModerationContextKind = z.infer<typeof moderationContextKindSchema>;\n\n/** Where a post, poll, comment, product or review stands. */\nexport const MODERATION_STATE_VALUES = ['visible', 'hidden', 'removed'] as const;\nexport const moderationStateSchema = z.enum(MODERATION_STATE_VALUES);\nexport type ModerationState = z.infer<typeof moderationStateSchema>;\n\nexport const MODERATION_ACTION_VALUES = ['dismiss', 'hide', 'restore', 'remove'] as const;\nexport const moderationActionSchema = z.enum(MODERATION_ACTION_VALUES);\nexport type ModerationAction = z.infer<typeof moderationActionSchema>;\n\n/**\n * Which decisions apply to which kind of thing: everything, except that a\n * product is never removed by staff (it is its team's work; hiding it takes it\n * out of Discover and off every page but its team's). The decision dialog\n * offers these, and the server refuses anything else with\n * `MODERATION_ACTION_INVALID`. Exhaustive by type.\n */\nexport const MODERATION_ACTIONS_BY_TARGET_KIND = {\n post: MODERATION_ACTION_VALUES,\n poll: MODERATION_ACTION_VALUES,\n comment: MODERATION_ACTION_VALUES,\n review: MODERATION_ACTION_VALUES,\n product: ['dismiss', 'hide', 'restore'],\n} as const satisfies Record<ReportTargetKind, readonly ModerationAction[]>;\n\nconst count = z.number().int().min(0);\n\n/** One post, poll, comment, product or review as moderation sees it. */\nexport const moderationItemSchema = z.object({\n target: z.object({ kind: reportTargetKindSchema, id: objectIdSchema }),\n /**\n * Where it is read in the app: the post or poll itself, or the one a comment\n * is on; the product page for a product or a review.\n */\n content: z.object({ kind: moderationContextKindSchema, id: objectIdSchema }),\n /**\n * Its first words, cut to `MODERATION_EXCERPT_MAX`: a poll's question, a\n * product's name and tagline, a review's stars and text.\n */\n excerpt: z.string(),\n /** Who wrote it; for a product, its owner. */\n author: z.object({\n id: objectIdSchema,\n displayName: z.string(),\n avatarUrl: z.url().nullable(),\n }),\n createdAt: z.iso.datetime(),\n state: moderationStateSchema,\n reports: z.object({\n /** Reports nobody has decided on. */\n open: count,\n /** Every report ever filed about it. */\n total: count,\n /** Why it was reported, among the open ones, most-given first. */\n reasons: z.array(z.object({ reason: reportReasonSchema, count })),\n /** The reporters' notes, among the open ones, newest first (a few, not all). */\n notes: z.array(z.string()),\n /** When the oldest open report was filed. Null when none is open. */\n oldestOpenAt: z.iso.datetime().nullable(),\n }),\n /** What the asking staff member may do to it now. */\n viewer: z.object({\n canDismiss: z.boolean(),\n canHide: z.boolean(),\n canRestore: z.boolean(),\n canRemove: z.boolean(),\n }),\n});\nexport type ModerationItem = z.infer<typeof moderationItemSchema>;\n\nexport const moderationPageSchema = paginatedSchema(moderationItemSchema);\nexport type ModerationPage = z.infer<typeof moderationPageSchema>;\n\n/** `GET /moderation/content`. Visible when not said. */\nexport const moderationContentQuerySchema = paginationQuerySchema.extend({\n state: z.preprocess(emptyStringToUndefined, moderationStateSchema.default('visible')),\n});\nexport type ModerationContentQuery = z.infer<typeof moderationContentQuerySchema>;\nexport type ModerationContentQueryInput = z.input<typeof moderationContentQuerySchema>;\n\n/** `POST /moderation/:contentKind/:contentId/decision`: what is decided on. */\nexport const moderationTargetParamsSchema = z.object({\n contentKind: reportTargetKindSchema,\n contentId: objectIdSchema,\n});\nexport type ModerationTargetParams = z.infer<typeof moderationTargetParamsSchema>;\n\n/** `POST /moderation/:contentKind/:contentId/decision`. */\nexport const moderationDecisionBodySchema = z.object({\n action: moderationActionSchema,\n /** Why, for the audit log. Optional: an obvious spam removal needs no essay. */\n note: optionalTrimmedString(MODERATION_NOTE_MAX),\n});\nexport type ModerationDecisionBody = z.infer<typeof moderationDecisionBodySchema>;\n"]}
|