ad2app-lib 1.29.0 → 1.31.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/dist/analytics/index.d.ts +25 -0
- package/dist/analytics/index.js +8 -0
- package/dist/publish-limits/index.d.ts +30 -0
- package/dist/publish-limits/index.js +32 -0
- package/dist/types/I_User.d.ts +31 -0
- package/package.json +2 -2
- package/src/analytics/index.test.ts +2 -0
- package/src/analytics/index.ts +41 -0
- package/src/publish-limits/index.ts +32 -0
- package/src/types/I_User.ts +33 -0
- package/src/types/onboarding-state.test.ts +66 -0
|
@@ -33,6 +33,8 @@ export declare const EVENTS: {
|
|
|
33
33
|
readonly LOGGED_IN: "logged_in";
|
|
34
34
|
readonly SOCIAL_ACCOUNT_CONNECTED: "social_account_connected";
|
|
35
35
|
readonly COMPOSE_STARTED: "compose_started";
|
|
36
|
+
readonly COMPOSE_STEP_VIEWED: "compose_step_viewed";
|
|
37
|
+
readonly COMPOSE_BLOCKED: "compose_blocked";
|
|
36
38
|
readonly POST_SCHEDULED: "post_scheduled";
|
|
37
39
|
readonly POST_PUBLISHED: "post_published";
|
|
38
40
|
readonly PUBLISH_FAILED: "publish_failed";
|
|
@@ -147,6 +149,19 @@ export type AppLocale = 'en' | 'pl';
|
|
|
147
149
|
* absent/unclassifiable error is `'unknown'`. Do NOT rename these values after
|
|
148
150
|
* they ship — they are wire values, historical data does not migrate.
|
|
149
151
|
*/
|
|
152
|
+
/**
|
|
153
|
+
* Which wizard step the composer is showing. Wire values, not indexes: a step
|
|
154
|
+
* reordered or inserted would silently re-label every historical row if this
|
|
155
|
+
* carried 0-3 (AD2-1371).
|
|
156
|
+
*/
|
|
157
|
+
export type ComposeStep = 'platforms' | 'content' | 'details' | 'review';
|
|
158
|
+
/**
|
|
159
|
+
* Why the composer refused to advance. The readiness summary renders LOCALISED
|
|
160
|
+
* PROSE that names platforms and field labels, and the caption itself is user
|
|
161
|
+
* content, so neither may ride the event: only the class does. Same contract as
|
|
162
|
+
* PublishFailureReason. Do NOT rename after shipping — wire values.
|
|
163
|
+
*/
|
|
164
|
+
export type ComposeBlockReason = 'empty' | 'missing_fields' | 'over_limit' | 'media' | 'channel_blocked' | 'poll';
|
|
150
165
|
export type PublishFailureReason = 'auth' | 'rate_limit' | 'media' | 'content' | 'network' | 'platform' | 'unknown';
|
|
151
166
|
/** Property shape per event. Keeps emitters honest across repos. */
|
|
152
167
|
export interface EventProperties {
|
|
@@ -211,6 +226,16 @@ export interface EventProperties {
|
|
|
211
226
|
[EVENTS.COMPOSE_STARTED]: {
|
|
212
227
|
source?: 'wizard' | 'quick';
|
|
213
228
|
};
|
|
229
|
+
[EVENTS.COMPOSE_STEP_VIEWED]: {
|
|
230
|
+
step: ComposeStep;
|
|
231
|
+
};
|
|
232
|
+
[EVENTS.COMPOSE_BLOCKED]: {
|
|
233
|
+
step: ComposeStep;
|
|
234
|
+
/** Distinct reason classes present, deduped. Never the rendered prose. */
|
|
235
|
+
reasons: ComposeBlockReason[];
|
|
236
|
+
/** How many readiness lines the user faced, which `reasons` dedupes away. */
|
|
237
|
+
reason_count: number;
|
|
238
|
+
};
|
|
214
239
|
[EVENTS.POST_SCHEDULED]: {
|
|
215
240
|
platforms: string[];
|
|
216
241
|
platform_count: number;
|
package/dist/analytics/index.js
CHANGED
|
@@ -43,6 +43,14 @@ exports.EVENTS = {
|
|
|
43
43
|
LOGGED_IN: 'logged_in',
|
|
44
44
|
SOCIAL_ACCOUNT_CONNECTED: 'social_account_connected',
|
|
45
45
|
COMPOSE_STARTED: 'compose_started',
|
|
46
|
+
// AD2-1371: the composer was a black box between COMPOSE_STARTED and
|
|
47
|
+
// POST_SCHEDULED. Measured 2026-08-28: of seven users who connected an
|
|
48
|
+
// account and never scheduled, the one who reached the composer opened it
|
|
49
|
+
// twice across two days and left both times, and the event stream carried
|
|
50
|
+
// nothing between arriving and leaving. These two say where they stopped
|
|
51
|
+
// and what stopped them.
|
|
52
|
+
COMPOSE_STEP_VIEWED: 'compose_step_viewed',
|
|
53
|
+
COMPOSE_BLOCKED: 'compose_blocked',
|
|
46
54
|
POST_SCHEDULED: 'post_scheduled',
|
|
47
55
|
POST_PUBLISHED: 'post_published', // server-confirmed (backend)
|
|
48
56
|
PUBLISH_FAILED: 'publish_failed', // per-platform publish failure, server-owned (backend, AD2-999)
|
|
@@ -10,6 +10,36 @@
|
|
|
10
10
|
* ad2app-frontend/src/components/social/compose/platformConstants.ts
|
|
11
11
|
* (PLATFORM_CONFIGS[*].charLimit / maxImages / maxVideos / acceptsVideo /
|
|
12
12
|
* acceptsImages / metadata.mediaConstraints.maxVideoDuration).
|
|
13
|
+
*
|
|
14
|
+
* CAVEAT ON THAT WORD "CORRECTED" (AD2-1369, measured 2026-08-28). It holds for
|
|
15
|
+
* the character and count fields. It does NOT hold for maxVideoDurationSec, which
|
|
16
|
+
* was inherited from platformConstants.ts wholesale and never checked against any
|
|
17
|
+
* platform. Five platforms share the value 3600, which is a default rather than a
|
|
18
|
+
* researched figure, and comparing the whole column against the export-specs
|
|
19
|
+
* registry (.claude/skills/ad2app-graphics/data/export-specs.jsonl) shows it
|
|
20
|
+
* disagrees with platform truth in both directions:
|
|
21
|
+
*
|
|
22
|
+
* platform ours platform's own figure
|
|
23
|
+
* twitter 3600 140s free; Premium up to 4h -> ours is ABOVE the free tier
|
|
24
|
+
* instagram 3600 900s (15 min) -> ours is ABOVE
|
|
25
|
+
* linkedin 3600 600s (10 min) -> ours is ABOVE
|
|
26
|
+
* pinterest 900 300s organic (900s is the ADS product) -> ours uses the ads figure
|
|
27
|
+
* bluesky 60 180s -> ours is BELOW
|
|
28
|
+
* facebook 3600 241 min -> ours is below, conservative
|
|
29
|
+
* tiktok 600 600s via API -> matches
|
|
30
|
+
* threads 300 300s -> matches
|
|
31
|
+
* youtube 43200 43200s for verified accounts -> matches (verified only)
|
|
32
|
+
* reddit 3600 not recorded in the registry -> unverified
|
|
33
|
+
*
|
|
34
|
+
* An ABOVE-truth value is the worse half: we accept a video the platform will
|
|
35
|
+
* reject, so the failure surfaces late, at publish time, instead of in compose.
|
|
36
|
+
* A BELOW-truth value silently blocks something the user is allowed to post.
|
|
37
|
+
*
|
|
38
|
+
* Nothing is changed here on purpose. Each row is a live publishing behaviour, and
|
|
39
|
+
* the vendor we publish through (Zernio) may impose its own caps that none of these
|
|
40
|
+
* numbers reflect; I could find no Zernio-side duration limits this session. Fixing
|
|
41
|
+
* a row means verifying BOTH the platform figure and Zernio's, per platform, which
|
|
42
|
+
* is its own ticket. Until then this table tells the truth about itself.
|
|
13
43
|
* - MAX_UPLOAD_BYTES matches ad2app-backend
|
|
14
44
|
* src/modules/late/late-media-upload.service.ts MAX_FILE_SIZE_BYTES + FR-105.
|
|
15
45
|
*
|
|
@@ -11,6 +11,36 @@
|
|
|
11
11
|
* ad2app-frontend/src/components/social/compose/platformConstants.ts
|
|
12
12
|
* (PLATFORM_CONFIGS[*].charLimit / maxImages / maxVideos / acceptsVideo /
|
|
13
13
|
* acceptsImages / metadata.mediaConstraints.maxVideoDuration).
|
|
14
|
+
*
|
|
15
|
+
* CAVEAT ON THAT WORD "CORRECTED" (AD2-1369, measured 2026-08-28). It holds for
|
|
16
|
+
* the character and count fields. It does NOT hold for maxVideoDurationSec, which
|
|
17
|
+
* was inherited from platformConstants.ts wholesale and never checked against any
|
|
18
|
+
* platform. Five platforms share the value 3600, which is a default rather than a
|
|
19
|
+
* researched figure, and comparing the whole column against the export-specs
|
|
20
|
+
* registry (.claude/skills/ad2app-graphics/data/export-specs.jsonl) shows it
|
|
21
|
+
* disagrees with platform truth in both directions:
|
|
22
|
+
*
|
|
23
|
+
* platform ours platform's own figure
|
|
24
|
+
* twitter 3600 140s free; Premium up to 4h -> ours is ABOVE the free tier
|
|
25
|
+
* instagram 3600 900s (15 min) -> ours is ABOVE
|
|
26
|
+
* linkedin 3600 600s (10 min) -> ours is ABOVE
|
|
27
|
+
* pinterest 900 300s organic (900s is the ADS product) -> ours uses the ads figure
|
|
28
|
+
* bluesky 60 180s -> ours is BELOW
|
|
29
|
+
* facebook 3600 241 min -> ours is below, conservative
|
|
30
|
+
* tiktok 600 600s via API -> matches
|
|
31
|
+
* threads 300 300s -> matches
|
|
32
|
+
* youtube 43200 43200s for verified accounts -> matches (verified only)
|
|
33
|
+
* reddit 3600 not recorded in the registry -> unverified
|
|
34
|
+
*
|
|
35
|
+
* An ABOVE-truth value is the worse half: we accept a video the platform will
|
|
36
|
+
* reject, so the failure surfaces late, at publish time, instead of in compose.
|
|
37
|
+
* A BELOW-truth value silently blocks something the user is allowed to post.
|
|
38
|
+
*
|
|
39
|
+
* Nothing is changed here on purpose. Each row is a live publishing behaviour, and
|
|
40
|
+
* the vendor we publish through (Zernio) may impose its own caps that none of these
|
|
41
|
+
* numbers reflect; I could find no Zernio-side duration limits this session. Fixing
|
|
42
|
+
* a row means verifying BOTH the platform figure and Zernio's, per platform, which
|
|
43
|
+
* is its own ticket. Until then this table tells the truth about itself.
|
|
14
44
|
* - MAX_UPLOAD_BYTES matches ad2app-backend
|
|
15
45
|
* src/modules/late/late-media-upload.service.ts MAX_FILE_SIZE_BYTES + FR-105.
|
|
16
46
|
*
|
|
@@ -107,6 +137,8 @@ exports.PUBLISH_LIMITS = {
|
|
|
107
137
|
charLimit: 300,
|
|
108
138
|
maxImages: 4,
|
|
109
139
|
maxVideos: 1,
|
|
140
|
+
// 60 is OURS, not Bluesky's. Bluesky allows 180s (its social-app constants.ts sets
|
|
141
|
+
// VIDEO_MAX_DURATION_MS = 3 * 60 * 1000). Left as-is deliberately, see the header.
|
|
110
142
|
maxVideoDurationSec: 60,
|
|
111
143
|
acceptsVideo: true,
|
|
112
144
|
acceptsImages: true,
|
package/dist/types/I_User.d.ts
CHANGED
|
@@ -10,8 +10,39 @@ export declare class I_User {
|
|
|
10
10
|
has_connected_platforms?: boolean;
|
|
11
11
|
influencer?: I_Influencer;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The answer an account has recorded about the onboarding tour.
|
|
15
|
+
*
|
|
16
|
+
* Three values, and the difference between the last two is the whole point:
|
|
17
|
+
* 'skipped' | 'completed' — a recorded answer. Never re-teach this user.
|
|
18
|
+
* null — no record yet. A genuinely new user; show it.
|
|
19
|
+
* ABSENT (the field is missing entirely) — the server could not read the
|
|
20
|
+
* column. Decide NOTHING.
|
|
21
|
+
*
|
|
22
|
+
* Absent is deliberately not `null`. Collapsing them shows the tour to every
|
|
23
|
+
* existing user in the window between a deploy and its manual migration
|
|
24
|
+
* (migrations here are run by hand, never on deploy).
|
|
25
|
+
*/
|
|
26
|
+
export type OnboardingState = 'skipped' | 'completed';
|
|
13
27
|
export declare class I_SignedUser extends I_User {
|
|
14
28
|
accessToken: string;
|
|
29
|
+
/**
|
|
30
|
+
* AD2-1331 AC3 (Maciej's review, 2026-08-25). Every response that hands back
|
|
31
|
+
* a session carries this, not just `authenticate` — the client replaces its
|
|
32
|
+
* authed slice wholesale with whichever payload arrives, so a path that omits
|
|
33
|
+
* it leaves the answer unknown for the rest of the session (AD2-1329 AC4).
|
|
34
|
+
*
|
|
35
|
+
* It lives HERE because AC3 chose an echoing PATCH over a failing one
|
|
36
|
+
* specifically to remove the forward-only rule from the client, and that only
|
|
37
|
+
* works if both sides agree on the shape. Until this shipped, the field was
|
|
38
|
+
* written by the backend, read by the frontend, and declared by neither: the
|
|
39
|
+
* frontend had to cast through `unknown` to see it, and a rename on either
|
|
40
|
+
* side would have type-checked clean while the onboarding funnel silently
|
|
41
|
+
* went dark.
|
|
42
|
+
*
|
|
43
|
+
* Optional on purpose — see OnboardingState: absent means "could not read".
|
|
44
|
+
*/
|
|
45
|
+
onboardingState?: OnboardingState | null;
|
|
15
46
|
}
|
|
16
47
|
export declare enum UserRoles {
|
|
17
48
|
ADMIN = "ADMIN",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ad2app-lib",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.31.0",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"prepare": "npm run build"
|
|
48
48
|
},
|
|
49
49
|
"keywords": [],
|
|
50
|
-
"author": "Maciej
|
|
50
|
+
"author": "Maciej G\u00f3rski@ad2.app",
|
|
51
51
|
"license": "ISC",
|
|
52
52
|
"description": "Package to share types and utils across the ad2app projects",
|
|
53
53
|
"dependencies": {
|
|
@@ -89,6 +89,8 @@ const EVENT_PROPERTY_WITNESS: { [E in keyof EventProperties]: EventProperties[E]
|
|
|
89
89
|
[EVENTS.LOGGED_IN]: {},
|
|
90
90
|
[EVENTS.SOCIAL_ACCOUNT_CONNECTED]: { platform: "instagram" },
|
|
91
91
|
[EVENTS.COMPOSE_STARTED]: {},
|
|
92
|
+
[EVENTS.COMPOSE_STEP_VIEWED]: { step: "content" },
|
|
93
|
+
[EVENTS.COMPOSE_BLOCKED]: { step: "content", reasons: ["missing_fields"], reason_count: 1 },
|
|
92
94
|
[EVENTS.POST_SCHEDULED]: { platforms: ["instagram"], platform_count: 1 },
|
|
93
95
|
[EVENTS.POST_PUBLISHED]: { platforms: ["instagram"], platform_count: 1, status: "success", platform: "instagram" },
|
|
94
96
|
[EVENTS.PUBLISH_FAILED]: { platform: "instagram", reason_class: "auth", post_id: "post-1", platform_count: 1 },
|
package/src/analytics/index.ts
CHANGED
|
@@ -47,6 +47,14 @@ export const EVENTS = {
|
|
|
47
47
|
LOGGED_IN: 'logged_in',
|
|
48
48
|
SOCIAL_ACCOUNT_CONNECTED: 'social_account_connected',
|
|
49
49
|
COMPOSE_STARTED: 'compose_started',
|
|
50
|
+
// AD2-1371: the composer was a black box between COMPOSE_STARTED and
|
|
51
|
+
// POST_SCHEDULED. Measured 2026-08-28: of seven users who connected an
|
|
52
|
+
// account and never scheduled, the one who reached the composer opened it
|
|
53
|
+
// twice across two days and left both times, and the event stream carried
|
|
54
|
+
// nothing between arriving and leaving. These two say where they stopped
|
|
55
|
+
// and what stopped them.
|
|
56
|
+
COMPOSE_STEP_VIEWED: 'compose_step_viewed',
|
|
57
|
+
COMPOSE_BLOCKED: 'compose_blocked',
|
|
50
58
|
POST_SCHEDULED: 'post_scheduled',
|
|
51
59
|
POST_PUBLISHED: 'post_published', // server-confirmed (backend)
|
|
52
60
|
PUBLISH_FAILED: 'publish_failed', // per-platform publish failure, server-owned (backend, AD2-999)
|
|
@@ -198,6 +206,31 @@ export type AppLocale = 'en' | 'pl';
|
|
|
198
206
|
* absent/unclassifiable error is `'unknown'`. Do NOT rename these values after
|
|
199
207
|
* they ship — they are wire values, historical data does not migrate.
|
|
200
208
|
*/
|
|
209
|
+
/**
|
|
210
|
+
* Which wizard step the composer is showing. Wire values, not indexes: a step
|
|
211
|
+
* reordered or inserted would silently re-label every historical row if this
|
|
212
|
+
* carried 0-3 (AD2-1371).
|
|
213
|
+
*/
|
|
214
|
+
export type ComposeStep =
|
|
215
|
+
| 'platforms' // step 1 — pick the accounts to post to
|
|
216
|
+
| 'content' // step 2 — caption and media
|
|
217
|
+
| 'details' // step 3 — per-platform required fields
|
|
218
|
+
| 'review'; // step 4 — schedule or publish
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Why the composer refused to advance. The readiness summary renders LOCALISED
|
|
222
|
+
* PROSE that names platforms and field labels, and the caption itself is user
|
|
223
|
+
* content, so neither may ride the event: only the class does. Same contract as
|
|
224
|
+
* PublishFailureReason. Do NOT rename after shipping — wire values.
|
|
225
|
+
*/
|
|
226
|
+
export type ComposeBlockReason =
|
|
227
|
+
| 'empty' // no caption and no media
|
|
228
|
+
| 'missing_fields' // a platform's required fields are unset
|
|
229
|
+
| 'over_limit' // caption longer than a selected platform allows
|
|
230
|
+
| 'media' // media rejected (format, size, duration, count)
|
|
231
|
+
| 'channel_blocked' // the channel itself refused (e.g. a failed prerequisite read)
|
|
232
|
+
| 'poll'; // an incomplete or unpostable poll
|
|
233
|
+
|
|
201
234
|
export type PublishFailureReason =
|
|
202
235
|
| 'auth' // token expired / revoked / missing scope / permission denied
|
|
203
236
|
| 'rate_limit' // provider throttled us (429 / quota)
|
|
@@ -251,6 +284,14 @@ export interface EventProperties {
|
|
|
251
284
|
connection_ordinal?: number;
|
|
252
285
|
};
|
|
253
286
|
[EVENTS.COMPOSE_STARTED]: { source?: 'wizard' | 'quick' };
|
|
287
|
+
[EVENTS.COMPOSE_STEP_VIEWED]: { step: ComposeStep };
|
|
288
|
+
[EVENTS.COMPOSE_BLOCKED]: {
|
|
289
|
+
step: ComposeStep;
|
|
290
|
+
/** Distinct reason classes present, deduped. Never the rendered prose. */
|
|
291
|
+
reasons: ComposeBlockReason[];
|
|
292
|
+
/** How many readiness lines the user faced, which `reasons` dedupes away. */
|
|
293
|
+
reason_count: number;
|
|
294
|
+
};
|
|
254
295
|
[EVENTS.POST_SCHEDULED]: {
|
|
255
296
|
platforms: string[];
|
|
256
297
|
platform_count: number;
|
|
@@ -10,6 +10,36 @@
|
|
|
10
10
|
* ad2app-frontend/src/components/social/compose/platformConstants.ts
|
|
11
11
|
* (PLATFORM_CONFIGS[*].charLimit / maxImages / maxVideos / acceptsVideo /
|
|
12
12
|
* acceptsImages / metadata.mediaConstraints.maxVideoDuration).
|
|
13
|
+
*
|
|
14
|
+
* CAVEAT ON THAT WORD "CORRECTED" (AD2-1369, measured 2026-08-28). It holds for
|
|
15
|
+
* the character and count fields. It does NOT hold for maxVideoDurationSec, which
|
|
16
|
+
* was inherited from platformConstants.ts wholesale and never checked against any
|
|
17
|
+
* platform. Five platforms share the value 3600, which is a default rather than a
|
|
18
|
+
* researched figure, and comparing the whole column against the export-specs
|
|
19
|
+
* registry (.claude/skills/ad2app-graphics/data/export-specs.jsonl) shows it
|
|
20
|
+
* disagrees with platform truth in both directions:
|
|
21
|
+
*
|
|
22
|
+
* platform ours platform's own figure
|
|
23
|
+
* twitter 3600 140s free; Premium up to 4h -> ours is ABOVE the free tier
|
|
24
|
+
* instagram 3600 900s (15 min) -> ours is ABOVE
|
|
25
|
+
* linkedin 3600 600s (10 min) -> ours is ABOVE
|
|
26
|
+
* pinterest 900 300s organic (900s is the ADS product) -> ours uses the ads figure
|
|
27
|
+
* bluesky 60 180s -> ours is BELOW
|
|
28
|
+
* facebook 3600 241 min -> ours is below, conservative
|
|
29
|
+
* tiktok 600 600s via API -> matches
|
|
30
|
+
* threads 300 300s -> matches
|
|
31
|
+
* youtube 43200 43200s for verified accounts -> matches (verified only)
|
|
32
|
+
* reddit 3600 not recorded in the registry -> unverified
|
|
33
|
+
*
|
|
34
|
+
* An ABOVE-truth value is the worse half: we accept a video the platform will
|
|
35
|
+
* reject, so the failure surfaces late, at publish time, instead of in compose.
|
|
36
|
+
* A BELOW-truth value silently blocks something the user is allowed to post.
|
|
37
|
+
*
|
|
38
|
+
* Nothing is changed here on purpose. Each row is a live publishing behaviour, and
|
|
39
|
+
* the vendor we publish through (Zernio) may impose its own caps that none of these
|
|
40
|
+
* numbers reflect; I could find no Zernio-side duration limits this session. Fixing
|
|
41
|
+
* a row means verifying BOTH the platform figure and Zernio's, per platform, which
|
|
42
|
+
* is its own ticket. Until then this table tells the truth about itself.
|
|
13
43
|
* - MAX_UPLOAD_BYTES matches ad2app-backend
|
|
14
44
|
* src/modules/late/late-media-upload.service.ts MAX_FILE_SIZE_BYTES + FR-105.
|
|
15
45
|
*
|
|
@@ -110,6 +140,8 @@ export const PUBLISH_LIMITS: Record<PublishPlatform, PlatformPublishLimits> = {
|
|
|
110
140
|
charLimit: 300,
|
|
111
141
|
maxImages: 4,
|
|
112
142
|
maxVideos: 1,
|
|
143
|
+
// 60 is OURS, not Bluesky's. Bluesky allows 180s (its social-app constants.ts sets
|
|
144
|
+
// VIDEO_MAX_DURATION_MS = 3 * 60 * 1000). Left as-is deliberately, see the header.
|
|
113
145
|
maxVideoDurationSec: 60,
|
|
114
146
|
acceptsVideo: true,
|
|
115
147
|
acceptsImages: true,
|
package/src/types/I_User.ts
CHANGED
|
@@ -13,8 +13,41 @@ export class I_User {
|
|
|
13
13
|
influencer?: I_Influencer;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The answer an account has recorded about the onboarding tour.
|
|
18
|
+
*
|
|
19
|
+
* Three values, and the difference between the last two is the whole point:
|
|
20
|
+
* 'skipped' | 'completed' — a recorded answer. Never re-teach this user.
|
|
21
|
+
* null — no record yet. A genuinely new user; show it.
|
|
22
|
+
* ABSENT (the field is missing entirely) — the server could not read the
|
|
23
|
+
* column. Decide NOTHING.
|
|
24
|
+
*
|
|
25
|
+
* Absent is deliberately not `null`. Collapsing them shows the tour to every
|
|
26
|
+
* existing user in the window between a deploy and its manual migration
|
|
27
|
+
* (migrations here are run by hand, never on deploy).
|
|
28
|
+
*/
|
|
29
|
+
export type OnboardingState = 'skipped' | 'completed';
|
|
30
|
+
|
|
16
31
|
export class I_SignedUser extends I_User {
|
|
17
32
|
accessToken: string;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* AD2-1331 AC3 (Maciej's review, 2026-08-25). Every response that hands back
|
|
36
|
+
* a session carries this, not just `authenticate` — the client replaces its
|
|
37
|
+
* authed slice wholesale with whichever payload arrives, so a path that omits
|
|
38
|
+
* it leaves the answer unknown for the rest of the session (AD2-1329 AC4).
|
|
39
|
+
*
|
|
40
|
+
* It lives HERE because AC3 chose an echoing PATCH over a failing one
|
|
41
|
+
* specifically to remove the forward-only rule from the client, and that only
|
|
42
|
+
* works if both sides agree on the shape. Until this shipped, the field was
|
|
43
|
+
* written by the backend, read by the frontend, and declared by neither: the
|
|
44
|
+
* frontend had to cast through `unknown` to see it, and a rename on either
|
|
45
|
+
* side would have type-checked clean while the onboarding funnel silently
|
|
46
|
+
* went dark.
|
|
47
|
+
*
|
|
48
|
+
* Optional on purpose — see OnboardingState: absent means "could not read".
|
|
49
|
+
*/
|
|
50
|
+
onboardingState?: OnboardingState | null;
|
|
18
51
|
}
|
|
19
52
|
|
|
20
53
|
export enum UserRoles {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { I_SignedUser, OnboardingState, UserRoles } from './I_User';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* AD2-1331 AC3 — the shared shape, pinned.
|
|
8
|
+
*
|
|
9
|
+
* These are mostly COMPILE-time assertions: the value of this file is that
|
|
10
|
+
* `tsc` fails if `onboardingState` is renamed, narrowed, or made required on
|
|
11
|
+
* one side of the wire. Before it existed the field was written by the backend,
|
|
12
|
+
* read by the frontend, and declared by neither, so either side could rename it
|
|
13
|
+
* and every type-check would still pass while the onboarding funnel went dark.
|
|
14
|
+
*
|
|
15
|
+
* The runtime assertions below exist so the file is also a real test rather
|
|
16
|
+
* than a silent type-only artefact that a test runner skips over.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
test('a signed user may carry a recorded answer', () => {
|
|
20
|
+
const user: I_SignedUser = {
|
|
21
|
+
id: 'u-1',
|
|
22
|
+
role: UserRoles.INFLUENCER,
|
|
23
|
+
accessToken: 'tok',
|
|
24
|
+
onboardingState: 'completed',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
assert.equal(user.onboardingState, 'completed');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('null is a value, and means "no record yet" — a new user', () => {
|
|
31
|
+
const user: I_SignedUser = {
|
|
32
|
+
id: 'u-1',
|
|
33
|
+
role: UserRoles.INFLUENCER,
|
|
34
|
+
accessToken: 'tok',
|
|
35
|
+
onboardingState: null,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// null must survive as null: the client tells it apart from absent, and
|
|
39
|
+
// only null means "show the tour".
|
|
40
|
+
assert.equal(user.onboardingState, null);
|
|
41
|
+
assert.ok('onboardingState' in user);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('ABSENT is distinct from null — the field may be omitted entirely', () => {
|
|
45
|
+
// The migration-pending case. If this ever stops compiling because the
|
|
46
|
+
// field was made required, the deploy window that follows would show the
|
|
47
|
+
// onboarding tour to the entire existing user base.
|
|
48
|
+
const user: I_SignedUser = {
|
|
49
|
+
id: 'u-1',
|
|
50
|
+
role: UserRoles.INFLUENCER,
|
|
51
|
+
accessToken: 'tok',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
assert.equal('onboardingState' in user, false);
|
|
55
|
+
assert.equal(user.onboardingState, undefined);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('the state union is exactly the two recorded answers', () => {
|
|
59
|
+
const every: OnboardingState[] = ['skipped', 'completed'];
|
|
60
|
+
|
|
61
|
+
assert.deepEqual(every, ['skipped', 'completed']);
|
|
62
|
+
// @ts-expect-error 'pending' is not a recorded answer; null/absent cover
|
|
63
|
+
// "no answer", and a third string would be a state nobody handles.
|
|
64
|
+
const invalid: OnboardingState = 'pending';
|
|
65
|
+
assert.equal(invalid, 'pending');
|
|
66
|
+
});
|