ad2app-lib 1.40.0 → 1.42.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/publish-limits/index.d.ts +5 -2
- package/dist/publish-limits/index.js +5 -2
- package/dist/types/I_AccessDenial.d.ts +49 -0
- package/dist/types/I_AccessDenial.js +43 -1
- package/dist/types/scheduling/I_SchedulingProUpgrade.d.ts +28 -1
- package/dist/types/scheduling/I_SchedulingProUpgrade.js +3 -2
- package/package.json +1 -1
- package/src/publish-limits/index.ts +5 -2
- package/src/types/I_AccessDenial.ts +52 -0
- package/src/types/access-denial.test.ts +70 -0
- package/src/types/scheduling/I_SchedulingProUpgrade.ts +36 -2
- package/src/types/scheduling/subscription-allowance.test.ts +75 -0
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* Canonical publish-limits source of truth for ad2app (AD2-1140 Wave 1).
|
|
3
3
|
*
|
|
4
4
|
* Single source of truth for (a) the per-platform compose/publish limits
|
|
5
|
-
* (character count, image/video counts, max video duration)
|
|
6
|
-
* universal media-upload byte cap ad2app itself enforces
|
|
5
|
+
* (character count, image/video counts, max video duration), (b) the
|
|
6
|
+
* universal media-upload byte cap ad2app itself enforces, and (c) which
|
|
7
|
+
* platforms a PUBLISHED post can be deleted from through our vendor
|
|
8
|
+
* (UNPUBLISHABLE_PLATFORMS / canUnpublish, ad2app-backend#303), kept here
|
|
9
|
+
* because it is the same kind of per-platform publishing capability.
|
|
7
10
|
*
|
|
8
11
|
* Provenance:
|
|
9
12
|
* - Per-platform values are the CORRECTED figures from
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
* Canonical publish-limits source of truth for ad2app (AD2-1140 Wave 1).
|
|
4
4
|
*
|
|
5
5
|
* Single source of truth for (a) the per-platform compose/publish limits
|
|
6
|
-
* (character count, image/video counts, max video duration)
|
|
7
|
-
* universal media-upload byte cap ad2app itself enforces
|
|
6
|
+
* (character count, image/video counts, max video duration), (b) the
|
|
7
|
+
* universal media-upload byte cap ad2app itself enforces, and (c) which
|
|
8
|
+
* platforms a PUBLISHED post can be deleted from through our vendor
|
|
9
|
+
* (UNPUBLISHABLE_PLATFORMS / canUnpublish, ad2app-backend#303), kept here
|
|
10
|
+
* because it is the same kind of per-platform publishing capability.
|
|
8
11
|
*
|
|
9
12
|
* Provenance:
|
|
10
13
|
* - Per-platform values are the CORRECTED figures from
|
|
@@ -26,14 +26,63 @@ export declare const ACCESS_DENIAL_CODES: {
|
|
|
26
26
|
readonly ENTITLEMENT_PRECONDITION: "entitlement-precondition";
|
|
27
27
|
};
|
|
28
28
|
export type AccessDenialCode = (typeof ACCESS_DENIAL_CODES)[keyof typeof ACCESS_DENIAL_CODES];
|
|
29
|
+
/**
|
|
30
|
+
* WHICH precondition the entitlement wall refused on (backend#305, spec 170).
|
|
31
|
+
*
|
|
32
|
+
* `ENTITLEMENT_PRECONDITION` above is the CLIENT contract: every cause below
|
|
33
|
+
* wants the same behaviour from a client — render the surface's own empty
|
|
34
|
+
* state, do not redirect, never send a paying user to buy a plan they already
|
|
35
|
+
* own — so a fourth code would force every consumer to re-learn a wall it
|
|
36
|
+
* already handles correctly, to carry information no consumer acts on.
|
|
37
|
+
*
|
|
38
|
+
* This is for whoever is READING. There is one throw site in the backend and
|
|
39
|
+
* four ways to reach it, and until now all four answered with the same string:
|
|
40
|
+
* "has no beta place", "subscription lapsed", "has connected nothing yet" and
|
|
41
|
+
* "was left in a state we do not recognise" were indistinguishable outside a
|
|
42
|
+
* database session. One beta tester was refused across /dashboard, /inbox,
|
|
43
|
+
* /settings and a post page — 21 denials in 30 days — and the only way to find
|
|
44
|
+
* out why was to open the database by hand.
|
|
45
|
+
*
|
|
46
|
+
* NOTHING HERE MAY SAY ANYTHING ABOUT THE ACCOUNT beyond which condition
|
|
47
|
+
* failed. These names travel to the browser. `UNRECOGNISED_PLAN` deliberately
|
|
48
|
+
* does not carry WHICH plan value was unrecognised — that belongs in the
|
|
49
|
+
* server-side log.
|
|
50
|
+
*/
|
|
51
|
+
export declare const ENTITLEMENT_PRECONDITIONS: {
|
|
52
|
+
/** No such user row. Unentitled, not merely unconnected. */
|
|
53
|
+
readonly UNKNOWN_ACCOUNT: "unknown-account";
|
|
54
|
+
/**
|
|
55
|
+
* A plan value the allow-list deliberately refuses: 'TRIALING', 'PAST_DUE',
|
|
56
|
+
* a future tier, or a typo. Split out of NO_ENTITLEMENT on purpose — both
|
|
57
|
+
* stay refused, and the fail-closed property of the allow-list is not
|
|
58
|
+
* touched, but a plan value we do not recognise is a DEFECT and a plain free
|
|
59
|
+
* account is not. Reported separately because that distinction is most of
|
|
60
|
+
* what makes a locked-out account diagnosable.
|
|
61
|
+
*/
|
|
62
|
+
readonly UNRECOGNISED_PLAN: "unrecognised-plan";
|
|
63
|
+
/** No paid plan and no live beta place; or a lapsed payer / lapsed place
|
|
64
|
+
* holder, who keeps read-only access to content we hold but has no claim on
|
|
65
|
+
* a live vendor read. */
|
|
66
|
+
readonly NO_ENTITLEMENT: "no-entitlement";
|
|
67
|
+
/** Entitled, and has connected nothing yet. They need an account, NOT a
|
|
68
|
+
* purchase — the distinction spec 144 drew in prose and nothing recorded. */
|
|
69
|
+
readonly NO_CONNECTION: "no-connection";
|
|
70
|
+
};
|
|
71
|
+
export type EntitlementPrecondition = (typeof ENTITLEMENT_PRECONDITIONS)[keyof typeof ENTITLEMENT_PRECONDITIONS];
|
|
29
72
|
/**
|
|
30
73
|
* The body shape those 403s answer with. `statusCode`/`error` are carried
|
|
31
74
|
* explicitly so the response stays byte-identical to Nest's default envelope
|
|
32
75
|
* and `code` is purely additive — no existing consumer sees a change.
|
|
76
|
+
*
|
|
77
|
+
* `precondition` is additive in exactly the same way, and OPTIONAL, which is
|
|
78
|
+
* what makes two promises true by construction: a client that ignores it
|
|
79
|
+
* behaves as it does today, and a response from a backend that predates it is
|
|
80
|
+
* still a valid body rather than something a consumer has to special-case.
|
|
33
81
|
*/
|
|
34
82
|
export interface I_AccessDenialBody {
|
|
35
83
|
statusCode: number;
|
|
36
84
|
error: string;
|
|
37
85
|
message: string;
|
|
38
86
|
code: AccessDenialCode;
|
|
87
|
+
precondition?: EntitlementPrecondition;
|
|
39
88
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ACCESS_DENIAL_CODES = void 0;
|
|
3
|
+
exports.ENTITLEMENT_PRECONDITIONS = exports.ACCESS_DENIAL_CODES = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Why a 403 happened, as a value a client can branch on.
|
|
6
6
|
*
|
|
@@ -28,3 +28,45 @@ exports.ACCESS_DENIAL_CODES = {
|
|
|
28
28
|
*/
|
|
29
29
|
ENTITLEMENT_PRECONDITION: "entitlement-precondition",
|
|
30
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* WHICH precondition the entitlement wall refused on (backend#305, spec 170).
|
|
33
|
+
*
|
|
34
|
+
* `ENTITLEMENT_PRECONDITION` above is the CLIENT contract: every cause below
|
|
35
|
+
* wants the same behaviour from a client — render the surface's own empty
|
|
36
|
+
* state, do not redirect, never send a paying user to buy a plan they already
|
|
37
|
+
* own — so a fourth code would force every consumer to re-learn a wall it
|
|
38
|
+
* already handles correctly, to carry information no consumer acts on.
|
|
39
|
+
*
|
|
40
|
+
* This is for whoever is READING. There is one throw site in the backend and
|
|
41
|
+
* four ways to reach it, and until now all four answered with the same string:
|
|
42
|
+
* "has no beta place", "subscription lapsed", "has connected nothing yet" and
|
|
43
|
+
* "was left in a state we do not recognise" were indistinguishable outside a
|
|
44
|
+
* database session. One beta tester was refused across /dashboard, /inbox,
|
|
45
|
+
* /settings and a post page — 21 denials in 30 days — and the only way to find
|
|
46
|
+
* out why was to open the database by hand.
|
|
47
|
+
*
|
|
48
|
+
* NOTHING HERE MAY SAY ANYTHING ABOUT THE ACCOUNT beyond which condition
|
|
49
|
+
* failed. These names travel to the browser. `UNRECOGNISED_PLAN` deliberately
|
|
50
|
+
* does not carry WHICH plan value was unrecognised — that belongs in the
|
|
51
|
+
* server-side log.
|
|
52
|
+
*/
|
|
53
|
+
exports.ENTITLEMENT_PRECONDITIONS = {
|
|
54
|
+
/** No such user row. Unentitled, not merely unconnected. */
|
|
55
|
+
UNKNOWN_ACCOUNT: "unknown-account",
|
|
56
|
+
/**
|
|
57
|
+
* A plan value the allow-list deliberately refuses: 'TRIALING', 'PAST_DUE',
|
|
58
|
+
* a future tier, or a typo. Split out of NO_ENTITLEMENT on purpose — both
|
|
59
|
+
* stay refused, and the fail-closed property of the allow-list is not
|
|
60
|
+
* touched, but a plan value we do not recognise is a DEFECT and a plain free
|
|
61
|
+
* account is not. Reported separately because that distinction is most of
|
|
62
|
+
* what makes a locked-out account diagnosable.
|
|
63
|
+
*/
|
|
64
|
+
UNRECOGNISED_PLAN: "unrecognised-plan",
|
|
65
|
+
/** No paid plan and no live beta place; or a lapsed payer / lapsed place
|
|
66
|
+
* holder, who keeps read-only access to content we hold but has no claim on
|
|
67
|
+
* a live vendor read. */
|
|
68
|
+
NO_ENTITLEMENT: "no-entitlement",
|
|
69
|
+
/** Entitled, and has connected nothing yet. They need an account, NOT a
|
|
70
|
+
* purchase — the distinction spec 144 drew in prose and nothing recorded. */
|
|
71
|
+
NO_CONNECTION: "no-connection",
|
|
72
|
+
};
|
|
@@ -6,6 +6,17 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export type SchedulingSubscriptionTier = 'free' | 'starter' | 'pro';
|
|
8
8
|
export type SchedulingSubscriptionStatus = 'active' | 'canceled' | 'expired';
|
|
9
|
+
/**
|
|
10
|
+
* Constructor input. Takes `maxAccounts`, or the deprecated `maxPlatforms` so
|
|
11
|
+
* code written against 1.40.0 keeps compiling; either way both fields are set.
|
|
12
|
+
*/
|
|
13
|
+
export type SchedulingSubscriptionInfoInput = Pick<SchedulingSubscriptionInfoDTO, 'tier' | 'status' | 'expiresAt'> & ({
|
|
14
|
+
maxAccounts: number;
|
|
15
|
+
maxPlatforms?: number;
|
|
16
|
+
} | {
|
|
17
|
+
/** @deprecated Pass `maxAccounts` (ad2app-backend#301). */
|
|
18
|
+
maxPlatforms: number;
|
|
19
|
+
});
|
|
9
20
|
/**
|
|
10
21
|
* Current subscription state for the authenticated user.
|
|
11
22
|
* Returned by GET /social/subscription.
|
|
@@ -14,8 +25,14 @@ export declare class SchedulingSubscriptionInfoDTO {
|
|
|
14
25
|
tier: SchedulingSubscriptionTier;
|
|
15
26
|
status: SchedulingSubscriptionStatus;
|
|
16
27
|
expiresAt: string | null;
|
|
28
|
+
/** How many social accounts the plan lets the user connect (free 0, starter 4, pro 10). */
|
|
29
|
+
maxAccounts: number;
|
|
30
|
+
/**
|
|
31
|
+
* @deprecated The cap counts accounts, not platforms (ad2app-backend#301).
|
|
32
|
+
* Read `maxAccounts`. Always equal to it; removed in a later release.
|
|
33
|
+
*/
|
|
17
34
|
maxPlatforms: number;
|
|
18
|
-
constructor(data:
|
|
35
|
+
constructor(data: SchedulingSubscriptionInfoInput);
|
|
19
36
|
}
|
|
20
37
|
/**
|
|
21
38
|
* Response DTO for POST /social/subscription/checkout.
|
|
@@ -38,6 +55,16 @@ export interface SchedulingPlanPriceDTO {
|
|
|
38
55
|
amount: number;
|
|
39
56
|
currency: string;
|
|
40
57
|
interval: string;
|
|
58
|
+
/**
|
|
59
|
+
* How many social accounts the plan lets the user connect. Optional only so
|
|
60
|
+
* 1.40.0-era literals still compile and because a backend older than this
|
|
61
|
+
* release omits it; read `maxAccounts ?? maxPlatforms`.
|
|
62
|
+
*/
|
|
63
|
+
maxAccounts?: number;
|
|
64
|
+
/**
|
|
65
|
+
* @deprecated The cap counts accounts, not platforms (ad2app-backend#301).
|
|
66
|
+
* Read `maxAccounts`. Always equal to it; removed in a later release.
|
|
67
|
+
*/
|
|
41
68
|
maxPlatforms: number;
|
|
42
69
|
}
|
|
43
70
|
export declare class SchedulingProductPricesDTO {
|
|
@@ -7,17 +7,18 @@
|
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
9
|
exports.SchedulingProUpgradeDTO = exports.SchedulingProductPricesDTO = exports.SchedulingPortalSessionResultDTO = exports.SchedulingCheckoutSessionResultDTO = exports.SchedulingSubscriptionInfoDTO = void 0;
|
|
10
|
-
// ── SchedulingSubscriptionInfoDTO ────────────────────────────────────────────
|
|
11
10
|
/**
|
|
12
11
|
* Current subscription state for the authenticated user.
|
|
13
12
|
* Returned by GET /social/subscription.
|
|
14
13
|
*/
|
|
15
14
|
class SchedulingSubscriptionInfoDTO {
|
|
16
15
|
constructor(data) {
|
|
16
|
+
const maxAccounts = 'maxAccounts' in data ? data.maxAccounts : data.maxPlatforms;
|
|
17
17
|
this.tier = data.tier;
|
|
18
18
|
this.status = data.status;
|
|
19
19
|
this.expiresAt = data.expiresAt;
|
|
20
|
-
this.
|
|
20
|
+
this.maxAccounts = maxAccounts;
|
|
21
|
+
this.maxPlatforms = maxAccounts;
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
24
|
exports.SchedulingSubscriptionInfoDTO = SchedulingSubscriptionInfoDTO;
|
package/package.json
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* Canonical publish-limits source of truth for ad2app (AD2-1140 Wave 1).
|
|
3
3
|
*
|
|
4
4
|
* Single source of truth for (a) the per-platform compose/publish limits
|
|
5
|
-
* (character count, image/video counts, max video duration)
|
|
6
|
-
* universal media-upload byte cap ad2app itself enforces
|
|
5
|
+
* (character count, image/video counts, max video duration), (b) the
|
|
6
|
+
* universal media-upload byte cap ad2app itself enforces, and (c) which
|
|
7
|
+
* platforms a PUBLISHED post can be deleted from through our vendor
|
|
8
|
+
* (UNPUBLISHABLE_PLATFORMS / canUnpublish, ad2app-backend#303), kept here
|
|
9
|
+
* because it is the same kind of per-platform publishing capability.
|
|
7
10
|
*
|
|
8
11
|
* Provenance:
|
|
9
12
|
* - Per-platform values are the CORRECTED figures from
|
|
@@ -29,14 +29,66 @@ export const ACCESS_DENIAL_CODES = {
|
|
|
29
29
|
export type AccessDenialCode =
|
|
30
30
|
(typeof ACCESS_DENIAL_CODES)[keyof typeof ACCESS_DENIAL_CODES];
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* WHICH precondition the entitlement wall refused on (backend#305, spec 170).
|
|
34
|
+
*
|
|
35
|
+
* `ENTITLEMENT_PRECONDITION` above is the CLIENT contract: every cause below
|
|
36
|
+
* wants the same behaviour from a client — render the surface's own empty
|
|
37
|
+
* state, do not redirect, never send a paying user to buy a plan they already
|
|
38
|
+
* own — so a fourth code would force every consumer to re-learn a wall it
|
|
39
|
+
* already handles correctly, to carry information no consumer acts on.
|
|
40
|
+
*
|
|
41
|
+
* This is for whoever is READING. There is one throw site in the backend and
|
|
42
|
+
* four ways to reach it, and until now all four answered with the same string:
|
|
43
|
+
* "has no beta place", "subscription lapsed", "has connected nothing yet" and
|
|
44
|
+
* "was left in a state we do not recognise" were indistinguishable outside a
|
|
45
|
+
* database session. One beta tester was refused across /dashboard, /inbox,
|
|
46
|
+
* /settings and a post page — 21 denials in 30 days — and the only way to find
|
|
47
|
+
* out why was to open the database by hand.
|
|
48
|
+
*
|
|
49
|
+
* NOTHING HERE MAY SAY ANYTHING ABOUT THE ACCOUNT beyond which condition
|
|
50
|
+
* failed. These names travel to the browser. `UNRECOGNISED_PLAN` deliberately
|
|
51
|
+
* does not carry WHICH plan value was unrecognised — that belongs in the
|
|
52
|
+
* server-side log.
|
|
53
|
+
*/
|
|
54
|
+
export const ENTITLEMENT_PRECONDITIONS = {
|
|
55
|
+
/** No such user row. Unentitled, not merely unconnected. */
|
|
56
|
+
UNKNOWN_ACCOUNT: "unknown-account",
|
|
57
|
+
/**
|
|
58
|
+
* A plan value the allow-list deliberately refuses: 'TRIALING', 'PAST_DUE',
|
|
59
|
+
* a future tier, or a typo. Split out of NO_ENTITLEMENT on purpose — both
|
|
60
|
+
* stay refused, and the fail-closed property of the allow-list is not
|
|
61
|
+
* touched, but a plan value we do not recognise is a DEFECT and a plain free
|
|
62
|
+
* account is not. Reported separately because that distinction is most of
|
|
63
|
+
* what makes a locked-out account diagnosable.
|
|
64
|
+
*/
|
|
65
|
+
UNRECOGNISED_PLAN: "unrecognised-plan",
|
|
66
|
+
/** No paid plan and no live beta place; or a lapsed payer / lapsed place
|
|
67
|
+
* holder, who keeps read-only access to content we hold but has no claim on
|
|
68
|
+
* a live vendor read. */
|
|
69
|
+
NO_ENTITLEMENT: "no-entitlement",
|
|
70
|
+
/** Entitled, and has connected nothing yet. They need an account, NOT a
|
|
71
|
+
* purchase — the distinction spec 144 drew in prose and nothing recorded. */
|
|
72
|
+
NO_CONNECTION: "no-connection",
|
|
73
|
+
} as const;
|
|
74
|
+
|
|
75
|
+
export type EntitlementPrecondition =
|
|
76
|
+
(typeof ENTITLEMENT_PRECONDITIONS)[keyof typeof ENTITLEMENT_PRECONDITIONS];
|
|
77
|
+
|
|
32
78
|
/**
|
|
33
79
|
* The body shape those 403s answer with. `statusCode`/`error` are carried
|
|
34
80
|
* explicitly so the response stays byte-identical to Nest's default envelope
|
|
35
81
|
* and `code` is purely additive — no existing consumer sees a change.
|
|
82
|
+
*
|
|
83
|
+
* `precondition` is additive in exactly the same way, and OPTIONAL, which is
|
|
84
|
+
* what makes two promises true by construction: a client that ignores it
|
|
85
|
+
* behaves as it does today, and a response from a backend that predates it is
|
|
86
|
+
* still a valid body rather than something a consumer has to special-case.
|
|
36
87
|
*/
|
|
37
88
|
export interface I_AccessDenialBody {
|
|
38
89
|
statusCode: number;
|
|
39
90
|
error: string;
|
|
40
91
|
message: string;
|
|
41
92
|
code: AccessDenialCode;
|
|
93
|
+
precondition?: EntitlementPrecondition;
|
|
42
94
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { strict as assert } from 'node:assert';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ACCESS_DENIAL_CODES,
|
|
6
|
+
ENTITLEMENT_PRECONDITIONS,
|
|
7
|
+
type EntitlementPrecondition,
|
|
8
|
+
type I_AccessDenialBody,
|
|
9
|
+
} from './I_AccessDenial';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Spec 170 T003. These four strings are a contract between three repos and a
|
|
13
|
+
* PostHog query someone will write against them: a rename is a silent break in
|
|
14
|
+
* whichever of the three does not get rebuilt, and the query just stops
|
|
15
|
+
* matching. Pinned here so a rename has to be a decision.
|
|
16
|
+
*/
|
|
17
|
+
test('the four precondition names are stable strings', () => {
|
|
18
|
+
assert.deepEqual(ENTITLEMENT_PRECONDITIONS, {
|
|
19
|
+
UNKNOWN_ACCOUNT: 'unknown-account',
|
|
20
|
+
UNRECOGNISED_PLAN: 'unrecognised-plan',
|
|
21
|
+
NO_ENTITLEMENT: 'no-entitlement',
|
|
22
|
+
NO_CONNECTION: 'no-connection',
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('the union covers every name and nothing else', () => {
|
|
27
|
+
const all: EntitlementPrecondition[] = Object.values(ENTITLEMENT_PRECONDITIONS);
|
|
28
|
+
assert.equal(new Set(all).size, 4);
|
|
29
|
+
// Exhaustiveness, checked by the compiler: adding a name without handling it
|
|
30
|
+
// here stops building.
|
|
31
|
+
for (const value of all) {
|
|
32
|
+
switch (value) {
|
|
33
|
+
case 'unknown-account':
|
|
34
|
+
case 'unrecognised-plan':
|
|
35
|
+
case 'no-entitlement':
|
|
36
|
+
case 'no-connection':
|
|
37
|
+
break;
|
|
38
|
+
default: {
|
|
39
|
+
const never: never = value;
|
|
40
|
+
throw new Error(`unhandled precondition ${String(never)}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('precondition is optional, so an older backend still sends a valid body', () => {
|
|
47
|
+
// The whole point of the field being optional: this has to compile.
|
|
48
|
+
const withoutIt: I_AccessDenialBody = {
|
|
49
|
+
statusCode: 403,
|
|
50
|
+
error: 'Forbidden',
|
|
51
|
+
message: 'Analytics require a paid plan with a connected Late profile',
|
|
52
|
+
code: ACCESS_DENIAL_CODES.ENTITLEMENT_PRECONDITION,
|
|
53
|
+
};
|
|
54
|
+
assert.equal(withoutIt.precondition, undefined);
|
|
55
|
+
|
|
56
|
+
const withIt: I_AccessDenialBody = {
|
|
57
|
+
...withoutIt,
|
|
58
|
+
precondition: ENTITLEMENT_PRECONDITIONS.NO_CONNECTION,
|
|
59
|
+
};
|
|
60
|
+
assert.equal(withIt.precondition, 'no-connection');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the denial codes are untouched', () => {
|
|
64
|
+
// FR-003: this feature adds a field, it does not renegotiate `code`.
|
|
65
|
+
assert.deepEqual(ACCESS_DENIAL_CODES, {
|
|
66
|
+
BETA_REQUIRED: 'beta-required',
|
|
67
|
+
SUBSCRIPTION_REQUIRED: 'subscription-required',
|
|
68
|
+
ENTITLEMENT_PRECONDITION: 'entitlement-precondition',
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -15,6 +15,22 @@ export type SchedulingSubscriptionStatus = 'active' | 'canceled' | 'expired';
|
|
|
15
15
|
|
|
16
16
|
// ── SchedulingSubscriptionInfoDTO ────────────────────────────────────────────
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Constructor input. Takes `maxAccounts`, or the deprecated `maxPlatforms` so
|
|
20
|
+
* code written against 1.40.0 keeps compiling; either way both fields are set.
|
|
21
|
+
*/
|
|
22
|
+
export type SchedulingSubscriptionInfoInput = Pick<
|
|
23
|
+
SchedulingSubscriptionInfoDTO,
|
|
24
|
+
'tier' | 'status' | 'expiresAt'
|
|
25
|
+
> &
|
|
26
|
+
(
|
|
27
|
+
| { maxAccounts: number; maxPlatforms?: number }
|
|
28
|
+
| {
|
|
29
|
+
/** @deprecated Pass `maxAccounts` (ad2app-backend#301). */
|
|
30
|
+
maxPlatforms: number;
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
|
|
18
34
|
/**
|
|
19
35
|
* Current subscription state for the authenticated user.
|
|
20
36
|
* Returned by GET /social/subscription.
|
|
@@ -23,13 +39,21 @@ export class SchedulingSubscriptionInfoDTO {
|
|
|
23
39
|
tier: SchedulingSubscriptionTier;
|
|
24
40
|
status: SchedulingSubscriptionStatus;
|
|
25
41
|
expiresAt: string | null;
|
|
42
|
+
/** How many social accounts the plan lets the user connect (free 0, starter 4, pro 10). */
|
|
43
|
+
maxAccounts: number;
|
|
44
|
+
/**
|
|
45
|
+
* @deprecated The cap counts accounts, not platforms (ad2app-backend#301).
|
|
46
|
+
* Read `maxAccounts`. Always equal to it; removed in a later release.
|
|
47
|
+
*/
|
|
26
48
|
maxPlatforms: number;
|
|
27
49
|
|
|
28
|
-
constructor(data:
|
|
50
|
+
constructor(data: SchedulingSubscriptionInfoInput) {
|
|
51
|
+
const maxAccounts = 'maxAccounts' in data ? data.maxAccounts : data.maxPlatforms;
|
|
29
52
|
this.tier = data.tier;
|
|
30
53
|
this.status = data.status;
|
|
31
54
|
this.expiresAt = data.expiresAt;
|
|
32
|
-
this.
|
|
55
|
+
this.maxAccounts = maxAccounts;
|
|
56
|
+
this.maxPlatforms = maxAccounts;
|
|
33
57
|
}
|
|
34
58
|
}
|
|
35
59
|
|
|
@@ -68,6 +92,16 @@ export interface SchedulingPlanPriceDTO {
|
|
|
68
92
|
amount: number;
|
|
69
93
|
currency: string;
|
|
70
94
|
interval: string;
|
|
95
|
+
/**
|
|
96
|
+
* How many social accounts the plan lets the user connect. Optional only so
|
|
97
|
+
* 1.40.0-era literals still compile and because a backend older than this
|
|
98
|
+
* release omits it; read `maxAccounts ?? maxPlatforms`.
|
|
99
|
+
*/
|
|
100
|
+
maxAccounts?: number;
|
|
101
|
+
/**
|
|
102
|
+
* @deprecated The cap counts accounts, not platforms (ad2app-backend#301).
|
|
103
|
+
* Read `maxAccounts`. Always equal to it; removed in a later release.
|
|
104
|
+
*/
|
|
71
105
|
maxPlatforms: number;
|
|
72
106
|
}
|
|
73
107
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { SchedulingPlanPriceDTO, SchedulingSubscriptionInfoDTO } from './I_SchedulingProUpgrade';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ad2app-backend#301 — the plan cap counts connected ACCOUNTS. `maxPlatforms`
|
|
8
|
+
* named the wrong unit on a public response, so `maxAccounts` replaces it and
|
|
9
|
+
* `maxPlatforms` stays, deprecated, with the identical value until readers
|
|
10
|
+
* (the MCP connector first) have moved.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
test('subscription info carries maxAccounts', () => {
|
|
14
|
+
const info = new SchedulingSubscriptionInfoDTO({
|
|
15
|
+
tier: 'starter',
|
|
16
|
+
status: 'active',
|
|
17
|
+
expiresAt: null,
|
|
18
|
+
maxAccounts: 4,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
assert.equal(info.maxAccounts, 4);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('the deprecated maxPlatforms always equals maxAccounts', () => {
|
|
25
|
+
const info = new SchedulingSubscriptionInfoDTO({
|
|
26
|
+
tier: 'pro',
|
|
27
|
+
status: 'active',
|
|
28
|
+
expiresAt: null,
|
|
29
|
+
maxAccounts: 10,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
assert.equal(info.maxPlatforms, 10);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('a plan price carries both fields', () => {
|
|
36
|
+
const price: SchedulingPlanPriceDTO = {
|
|
37
|
+
tier: 'starter',
|
|
38
|
+
amount: 1600,
|
|
39
|
+
currency: 'USD',
|
|
40
|
+
interval: 'month',
|
|
41
|
+
maxAccounts: 4,
|
|
42
|
+
maxPlatforms: 4,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
assert.equal(price.maxAccounts, price.maxPlatforms);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 1.41.0 is a minor release. Code written against 1.40.0 still passes
|
|
50
|
+
* `maxPlatforms` and builds plan-price literals without `maxAccounts`; both
|
|
51
|
+
* must keep compiling (backend and frontend master do exactly this).
|
|
52
|
+
*/
|
|
53
|
+
test('the 1.40.0 constructor call still works and fills both fields', () => {
|
|
54
|
+
const info = new SchedulingSubscriptionInfoDTO({
|
|
55
|
+
tier: 'starter',
|
|
56
|
+
status: 'active',
|
|
57
|
+
expiresAt: null,
|
|
58
|
+
maxPlatforms: 4,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
assert.equal(info.maxAccounts, 4);
|
|
62
|
+
assert.equal(info.maxPlatforms, 4);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a 1.40.0 plan-price literal without maxAccounts still type-checks', () => {
|
|
66
|
+
const price: SchedulingPlanPriceDTO = {
|
|
67
|
+
tier: 'pro',
|
|
68
|
+
amount: 3300,
|
|
69
|
+
currency: 'USD',
|
|
70
|
+
interval: 'month',
|
|
71
|
+
maxPlatforms: 10,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
assert.equal(price.maxAccounts ?? price.maxPlatforms, 10);
|
|
75
|
+
});
|