ad2app-lib 1.23.0 → 1.25.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.
@@ -36,6 +36,7 @@ export declare const EVENTS: {
36
36
  readonly SUBSCRIPTION_CANCELED: "subscription_canceled";
37
37
  readonly PAYWALL_SHOWN: "paywall_shown";
38
38
  readonly PAYWALL_DISMISSED: "paywall_dismissed";
39
+ readonly ACCESS_DENIED: "access_denied";
39
40
  readonly ONBOARDING_STEP_COMPLETED: "onboarding_step_completed";
40
41
  readonly EXIT_INTENT_SHOWN: "exit_intent_shown";
41
42
  readonly EXIT_INTENT_DISMISSED: "exit_intent_dismissed";
@@ -194,6 +195,11 @@ export interface EventProperties {
194
195
  page_views?: number;
195
196
  app_locale: AppLocale;
196
197
  };
198
+ [EVENTS.ACCESS_DENIED]: {
199
+ reason: 'beta-required' | 'subscription-required';
200
+ path?: string;
201
+ app_locale: AppLocale;
202
+ };
197
203
  [EVENTS.PAYWALL_DISMISSED]: {
198
204
  surface: 'upgrade_modal' | 'pricing_page';
199
205
  trigger: 'user_click' | 'limit_reached' | 'connect_wall';
@@ -45,6 +45,13 @@ exports.EVENTS = {
45
45
  // Measurement baseline (080) — paywall / onboarding / exit-intent (frontend-owned)
46
46
  PAYWALL_SHOWN: 'paywall_shown', // any monetization surface is shown (upgrade modal / pricing page)
47
47
  PAYWALL_DISMISSED: 'paywall_dismissed', // closed without converting
48
+ // AD2-1284: an entitlement wall rejected an API READ, which is control flow,
49
+ // not an error. Distinct from PAYWALL_SHOWN because nothing was shown: the
50
+ // driver classified a 403 at the choke point and the redirect follows. Before
51
+ // this event existed the only record was a captured $exception, which put
52
+ // expected paywall hits into Error Tracking and made that dashboard harder to
53
+ // trust — it is part of why a REAL retry defect sat unnoticed in the same list.
54
+ ACCESS_DENIED: 'access_denied', // entitlement 403 at the API choke point (beta wall / subscription)
48
55
  ONBOARDING_STEP_COMPLETED: 'onboarding_step_completed', // one step of a multi-step flow (complete-profile)
49
56
  EXIT_INTENT_SHOWN: 'exit_intent_shown', // exit-intent capture modal armed (065, verbatim wire values)
50
57
  EXIT_INTENT_DISMISSED: 'exit_intent_dismissed',
@@ -194,14 +194,16 @@ exports.PRIVACY_SECTIONS = [
194
194
  {
195
195
  kind: 'ul',
196
196
  items: [
197
- { text: '**Strictly necessary cookies:** required for authentication sessions and core platform functionality. Cannot be disabled without breaking the Service. Legal basis: Art. 6(1)(b), contract performance; no consent required. Duration: session cookies expire when you close your browser; authentication cookies expire after 30 days of inactivity.' },
197
+ { text: '**Strictly necessary cookies and equivalent device storage:** required for authentication sessions and core platform functionality. Cannot be disabled without breaking the Service. Legal basis: Art. 6(1)(b), contract performance; no consent required. Your session token is held in your browser\'s local storage rather than in a cookie; a cookie is used only as a fallback where local storage is unavailable (for example private browsing). Duration: your session expires after 30 days of inactivity. Each time you open the app the 30 days start again, and if you do not return within 30 days you are signed out.' },
198
198
  { text: '**Functional cookies:** set only in direct response to an action you take (e.g. selecting a language or theme preference), and strictly necessary to deliver that specific function you have requested. They do not track you across sessions beyond preserving your chosen setting. Legal basis: strictly necessary to fulfil your explicit request under Art. 173 of the Polish Telecommunications Act (ePrivacy); no separate consent required. Duration: up to 12 months, or cleared when you clear your browser data.' },
199
199
  { text: '**Analytics cookies (PostHog):** collect usage event data in pseudonymised form (other than the identifying flows described in Section 6), enable session replay (with form-field values masked; see Section 3), and capture error reports to help us understand and improve how the Service is used. Legal basis: Art. 6(1)(a), consent. **No analytics cookies are set and no analytics events are captured before you make a choice** in the cookie consent banner shown on first visit. If you accept, PostHog sets a first-party cookie (name beginning `ph_`) on the `ad2.app` domain, valid for up to 1 year, shared between our website and the app so you are not asked twice. If you decline, no analytics cookie is set and no events are collected. Analytics data is processed on PostHog Cloud EU servers in Frankfurt, Germany (see Section 6).' },
200
200
  ],
201
201
  },
202
202
  { kind: 'p', text: 'You may withdraw or update your cookie consent at any time via the "Cookie settings" link in the footer of our website, or on this Privacy Policy page in the app. Withdrawing analytics consent does not affect platform functionality.' },
203
- { kind: 'subheading', text: 'Browser local storage' },
204
- { kind: 'p', text: 'In addition to cookies, we use browser local storage to preserve application state between sessions. This includes: your language and theme preferences; a cached copy of your subscription tier and status (retained for up to 30 days then invalidated); and draft campaign deadline data. Local storage data is stored on your device only and is not transmitted to our servers independently of your normal usage. It is cleared when you clear your browser data or log out.' },
203
+ { kind: 'subheading', text: 'Browser local storage and on-device cache' },
204
+ { kind: 'p', text: 'In addition to cookies, we use browser local storage to preserve application state between sessions. This includes: your session token (see "Strictly necessary" above); your language and theme preferences; a cached copy of your subscription tier and status (retained for up to 30 days then invalidated); and draft campaign deadline data.' },
205
+ { kind: 'p', text: 'We also keep a working copy of data you have already loaded in your browser\'s IndexedDB storage, so the app can show your most recent screens immediately when you reopen it instead of leaving you on a loading spinner. This copy holds up to 50 of your most recent responses from our API and can include your posts and drafts, your analytics figures, your connected account details, and your inbox, which contains comments and messages written by other people on your social media posts. It is limited to data your account is already entitled to see, is scoped to the signed-in account so a different user signing in on the same device cannot read it, and is deleted when you sign out, when your session ends, or when you clear your browser data.' },
206
+ { kind: 'p', text: 'All of the above is stored on your device only and is not transmitted to our servers independently of your normal usage. You can remove it at any time by signing out or clearing your browser data.' },
205
207
  ],
206
208
  },
207
209
  {
@@ -206,14 +206,16 @@ exports.PRIVACY_SECTIONS_PL = [
206
206
  {
207
207
  kind: 'ul',
208
208
  items: [
209
- { text: '**Pliki cookie ściśle niezbędne:** wymagane do obsługi sesji uwierzytelniania i podstawowych funkcji platformy. Nie można ich wyłączyć bez zakłócenia działania Usługi. Podstawa prawna: art. 6 ust. 1 lit. b, wykonanie umowy; zgoda nie jest wymagana. Czas trwania: sesyjne pliki cookie wygasają po zamknięciu przeglądarki; uwierzytelniające pliki cookie wygasają po 30 dniach bezczynności.' },
209
+ { text: '**Pliki cookie ściśle niezbędne i równoważna pamięć urządzenia:** wymagane do obsługi sesji uwierzytelniania i podstawowych funkcji platformy. Nie można ich wyłączyć bez zakłócenia działania Usługi. Podstawa prawna: art. 6 ust. 1 lit. b, wykonanie umowy; zgoda nie jest wymagana. Token Twojej sesji przechowujemy w pamięci lokalnej przeglądarki, a nie w pliku cookie; plik cookie służy wyłącznie jako rozwiązanie zapasowe tam, gdzie pamięć lokalna jest niedostępna (na przykład w trybie prywatnym). Czas trwania: Twoja sesja wygasa po 30 dniach bezczynności. Przy każdym otwarciu aplikacji te 30 dni liczone jest od nowa, a jeżeli nie wrócisz w ciągu 30 dni, nastąpi wylogowanie.' },
210
210
  { text: '**Funkcjonalne pliki cookie:** ustawiane wyłącznie w bezpośredniej reakcji na podjętą przez Ciebie czynność (np. wybór języka lub motywu) i ściśle niezbędne do wykonania tej konkretnej, zażądanej przez Ciebie funkcji. Nie śledzą Cię między sesjami poza zachowaniem wybranego przez Ciebie ustawienia. Podstawa prawna: ścisła niezbędność do spełnienia Twojego wyraźnego żądania na podstawie art. 173 Prawa telekomunikacyjnego (ePrivacy); odrębna zgoda nie jest wymagana. Czas trwania: do 12 miesięcy lub do wyczyszczenia przez Ciebie danych przeglądarki.' },
211
211
  { text: '**Analityczne pliki cookie (PostHog):** zbierają dane o zdarzeniach korzystania w postaci spseudonimizowanej (poza przepływami identyfikującymi opisanymi w sekcji 6), umożliwiają nagrywanie sesji (z maskowaniem wartości wpisywanych w pola formularzy; zobacz sekcję 3) i rejestrują raporty o błędach, abyśmy mogli rozumieć i ulepszać sposób korzystania z Usługi. Podstawa prawna: art. 6 ust. 1 lit. a, zgoda. **Zanim dokonasz wyboru w banerze zgody na pliki cookie, wyświetlanym przy pierwszej wizycie, nie ustawiamy żadnych analitycznych plików cookie i nie rejestrujemy żadnych zdarzeń analitycznych.** Jeżeli wyrazisz zgodę, PostHog ustawia własny plik cookie (o nazwie zaczynającej się od `ph_`) w domenie `ad2.app`, ważny do 1 roku, współdzielony między naszą stroną internetową a aplikacją, aby nie pytać Cię o to dwa razy. Jeżeli odmówisz, żaden analityczny plik cookie nie zostanie ustawiony i żadne zdarzenia nie będą zbierane. Dane analityczne są przetwarzane na serwerach PostHog Cloud EU we Frankfurcie w Niemczech (zobacz sekcję 6).' },
212
212
  ],
213
213
  },
214
214
  { kind: 'p', text: 'Zgodę na pliki cookie możesz wycofać lub zaktualizować w każdej chwili poprzez link "Ustawienia cookie" w stopce naszej strony internetowej albo na stronie niniejszej Polityki Prywatności w aplikacji. Wycofanie zgody analitycznej nie wpływa na działanie platformy.' },
215
- { kind: 'subheading', text: 'Pamięć lokalna przeglądarki' },
216
- { kind: 'p', text: 'Poza plikami cookie korzystamy z pamięci lokalnej przeglądarki, aby zachować stan aplikacji między sesjami. Obejmuje to: Twoje preferencje języka i motywu; zbuforowaną kopię poziomu i statusu Twojej subskrypcji (przechowywaną do 30 dni, a następnie unieważnianą); oraz robocze dane o terminach kampanii. Dane z pamięci lokalnej są przechowywane wyłącznie na Twoim urządzeniu i nie są przesyłane na nasze serwery niezależnie od Twojego zwykłego korzystania z Usługi. Są usuwane, gdy wyczyścisz dane przeglądarki lub się wylogujesz.' },
215
+ { kind: 'subheading', text: 'Pamięć lokalna przeglądarki i podręczna kopia na urządzeniu' },
216
+ { kind: 'p', text: 'Poza plikami cookie korzystamy z pamięci lokalnej przeglądarki, aby zachować stan aplikacji między sesjami. Obejmuje to: token Twojej sesji (zobacz „ściśle niezbędne" powyżej); Twoje preferencje języka i motywu; zbuforowaną kopię poziomu i statusu Twojej subskrypcji (przechowywaną do 30 dni, a następnie unieważnianą); oraz robocze dane o terminach kampanii.' },
217
+ { kind: 'p', text: 'Przechowujemy również roboczą kopię danych, które już wcześniej wczytałeś, w pamięci IndexedDB Twojej przeglądarki, aby po ponownym otwarciu aplikacji od razu pokazać Twoje ostatnie ekrany zamiast zostawiać Cię przy animacji ładowania. Kopia ta obejmuje do 50 Twoich najnowszych odpowiedzi z naszego API i może zawierać Twoje posty i wersje robocze, Twoje dane statystyczne, dane połączonych kont oraz Twoją skrzynkę odbiorczą, która zawiera komentarze i wiadomości napisane przez inne osoby pod Twoimi postami w mediach społecznościowych. Ogranicza się do danych, do których Twoje konto i tak ma dostęp, jest przypisana do zalogowanego konta, więc inny użytkownik logujący się na tym samym urządzeniu nie może jej odczytać, i jest usuwana przy wylogowaniu, po zakończeniu sesji oraz po wyczyszczeniu danych przeglądarki.' },
218
+ { kind: 'p', text: 'Wszystkie powyższe dane są przechowywane wyłącznie na Twoim urządzeniu i nie są przesyłane na nasze serwery niezależnie od Twojego zwykłego korzystania z Usługi. Możesz je w każdej chwili usunąć, wylogowując się lub czyszcząc dane przeglądarki.' },
217
219
  ],
218
220
  },
219
221
  {
@@ -10,6 +10,24 @@
10
10
  */
11
11
  export declare const ANALYTICS_SOURCES: readonly ["daily-metrics", "follower-stats", "content-decay", "top-posts"];
12
12
  export type AnalyticsSource = (typeof ANALYTICS_SOURCES)[number];
13
+ /**
14
+ * The metrics whose visibility is decided by the connected platform mix rather
15
+ * than by the data itself.
16
+ *
17
+ * Not every metric is here. `engagements` is our own roll-up (likes + comments
18
+ * + shares + saves) and `followerGrowth` comes from the follower-stats surface,
19
+ * so neither is platform-reported and neither is gated.
20
+ */
21
+ export declare const GATED_METRICS: readonly ["impressions", "reach", "likes", "comments", "shares", "saves", "clicks", "views"];
22
+ export type GatedMetric = (typeof GATED_METRICS)[number];
23
+ /**
24
+ * Which gated metrics the current platform mix can actually report.
25
+ *
26
+ * An ABSENT entry means UNKNOWN, never unavailable — the producer omits the map
27
+ * when it could not determine the platform mix, and a consumer must fall
28
+ * through to showing the figure rather than withhold on a guess.
29
+ */
30
+ export type MetricAvailability = Partial<Record<GatedMetric, boolean>>;
13
31
  /**
14
32
  * Aggregated KPI figures for the selected date range.
15
33
  * Returned by GET /social/analytics.
@@ -50,6 +68,16 @@ export declare class SchedulingAnalyticsKpiDTO {
50
68
  * Absent/true = available.
51
69
  */
52
70
  reachAvailable?: boolean;
71
+ /**
72
+ * Structural availability for every gated metric, from the backend's
73
+ * per-platform capability map (spec 111). Supersedes the two flags above,
74
+ * which stay for consumers that only ever read those two.
75
+ *
76
+ * ABSENT means UNKNOWN, not unavailable: the backend omits this whole map
77
+ * when it could not determine the connected platform mix, and a consumer
78
+ * must show the figure rather than withhold on a guess.
79
+ */
80
+ metricAvailability?: MetricAvailability;
53
81
  constructor(data: SchedulingAnalyticsKpiDTO);
54
82
  }
55
83
  /**
@@ -6,7 +6,7 @@
6
6
  * per-post timeline snapshots, content decay windows, and follower stats.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.SchedulingFollowerStatDTO = exports.SchedulingContentDecayDTO = exports.SchedulingPostTimelineEntryDTO = exports.SchedulingBestTimeSlotDTO = exports.SchedulingAnalyticsParamsDTO = exports.SchedulingAnalyticsEntryDTO = exports.SchedulingAnalyticsKpiDTO = exports.ANALYTICS_SOURCES = void 0;
9
+ exports.SchedulingFollowerStatDTO = exports.SchedulingContentDecayDTO = exports.SchedulingPostTimelineEntryDTO = exports.SchedulingBestTimeSlotDTO = exports.SchedulingAnalyticsParamsDTO = exports.SchedulingAnalyticsEntryDTO = exports.SchedulingAnalyticsKpiDTO = exports.GATED_METRICS = exports.ANALYTICS_SOURCES = void 0;
10
10
  // ── Analytics sources (AD2-1045) ──────────────────────────────────────────────
11
11
  /**
12
12
  * The upstream sources an analytics response aggregates. Used by
@@ -18,6 +18,25 @@ exports.ANALYTICS_SOURCES = [
18
18
  'content-decay',
19
19
  'top-posts',
20
20
  ];
21
+ // ── Per-metric structural availability (spec 111) ─────────────────────────────
22
+ /**
23
+ * The metrics whose visibility is decided by the connected platform mix rather
24
+ * than by the data itself.
25
+ *
26
+ * Not every metric is here. `engagements` is our own roll-up (likes + comments
27
+ * + shares + saves) and `followerGrowth` comes from the follower-stats surface,
28
+ * so neither is platform-reported and neither is gated.
29
+ */
30
+ exports.GATED_METRICS = [
31
+ 'impressions',
32
+ 'reach',
33
+ 'likes',
34
+ 'comments',
35
+ 'shares',
36
+ 'saves',
37
+ 'clicks',
38
+ 'views',
39
+ ];
21
40
  // ── SchedulingAnalyticsKpiDTO ─────────────────────────────────────────────────
22
41
  /**
23
42
  * Aggregated KPI figures for the selected date range.
@@ -43,6 +62,9 @@ class SchedulingAnalyticsKpiDTO {
43
62
  if (data.reachAvailable !== undefined) {
44
63
  this.reachAvailable = data.reachAvailable;
45
64
  }
65
+ if (data.metricAvailability !== undefined) {
66
+ this.metricAvailability = data.metricAvailability;
67
+ }
46
68
  }
47
69
  }
48
70
  exports.SchedulingAnalyticsKpiDTO = SchedulingAnalyticsKpiDTO;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ad2app-lib",
3
- "version": "1.23.0",
3
+ "version": "1.25.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "commonjs",
@@ -88,6 +88,7 @@ const EVENT_PROPERTY_WITNESS: { [E in keyof EventProperties]: EventProperties[E]
88
88
  // 080 measurement baseline
89
89
  [EVENTS.PAYWALL_SHOWN]: { surface: "upgrade_modal", trigger: "user_click", hit_number: 1, app_locale: "en" },
90
90
  [EVENTS.PAYWALL_DISMISSED]: { surface: "upgrade_modal", trigger: "user_click", hit_number: 1, app_locale: "en" },
91
+ [EVENTS.ACCESS_DENIED]: { reason: "subscription-required", app_locale: "en" },
91
92
  [EVENTS.ONBOARDING_STEP_COMPLETED]: { flow: "complete_profile", step: "role", step_index: 1, app_locale: "en" },
92
93
  [EVENTS.EXIT_INTENT_SHOWN]: { surface: "connect_wall" },
93
94
  [EVENTS.EXIT_INTENT_DISMISSED]: { surface: "connect_wall" },
@@ -202,6 +203,28 @@ test("080 exit_intent_* wire values match the 065 component verbatim (fidelity g
202
203
  assert.equal(shown.surface, "connect_wall");
203
204
  });
204
205
 
206
+ test("AD2-1284 access_denied is locked, and its reasons mirror the backend's two 403s", () => {
207
+ // The wire name is final: Error Tracking noise was traded for this event, and
208
+ // renaming it would orphan whatever reads it.
209
+ assert.equal(EVENTS.ACCESS_DENIED, "access_denied");
210
+
211
+ // Both reasons must type-check — they are the two DIFFERENT 403s the backend
212
+ // sends, and accessDenied.ts's AccessDenial union is the other copy of this
213
+ // fact. A drift between them is the defect this pins.
214
+ const beta: EventProperties["access_denied"] = {
215
+ reason: "beta-required",
216
+ path: "/social/inbox/messages",
217
+ app_locale: "pl",
218
+ };
219
+ const sub: EventProperties["access_denied"] = {
220
+ reason: "subscription-required",
221
+ app_locale: "en",
222
+ };
223
+ assert.equal(beta.reason, "beta-required");
224
+ assert.equal(beta.path, "/social/inbox/messages");
225
+ assert.equal(sub.path, undefined);
226
+ });
227
+
205
228
  test("080 PAYWALL_HITS person prop is locked; hit_number stays the event-level truth", () => {
206
229
  assert.equal(PERSON_PROPS.PAYWALL_HITS, "paywall_hits");
207
230
  // The analytical counter lives on the event (person props are query-time-latest).
@@ -48,6 +48,13 @@ export const EVENTS = {
48
48
  // Measurement baseline (080) — paywall / onboarding / exit-intent (frontend-owned)
49
49
  PAYWALL_SHOWN: 'paywall_shown', // any monetization surface is shown (upgrade modal / pricing page)
50
50
  PAYWALL_DISMISSED: 'paywall_dismissed', // closed without converting
51
+ // AD2-1284: an entitlement wall rejected an API READ, which is control flow,
52
+ // not an error. Distinct from PAYWALL_SHOWN because nothing was shown: the
53
+ // driver classified a 403 at the choke point and the redirect follows. Before
54
+ // this event existed the only record was a captured $exception, which put
55
+ // expected paywall hits into Error Tracking and made that dashboard harder to
56
+ // trust — it is part of why a REAL retry defect sat unnoticed in the same list.
57
+ ACCESS_DENIED: 'access_denied', // entitlement 403 at the API choke point (beta wall / subscription)
51
58
  ONBOARDING_STEP_COMPLETED: 'onboarding_step_completed', // one step of a multi-step flow (complete-profile)
52
59
  EXIT_INTENT_SHOWN: 'exit_intent_shown', // exit-intent capture modal armed (065, verbatim wire values)
53
60
  EXIT_INTENT_DISMISSED: 'exit_intent_dismissed',
@@ -241,6 +248,14 @@ export interface EventProperties {
241
248
  page_views?: number;
242
249
  app_locale: AppLocale;
243
250
  };
251
+ // `reason` mirrors accessDenied.ts's AccessDenial union exactly — the two
252
+ // different 403s the backend sends. `path` is the request path, never a URL
253
+ // with query params (no PII in analytics).
254
+ [EVENTS.ACCESS_DENIED]: {
255
+ reason: 'beta-required' | 'subscription-required';
256
+ path?: string;
257
+ app_locale: AppLocale;
258
+ };
244
259
  [EVENTS.PAYWALL_DISMISSED]: {
245
260
  surface: 'upgrade_modal' | 'pricing_page';
246
261
  trigger: 'user_click' | 'limit_reached' | 'connect_wall';
@@ -205,14 +205,16 @@ export const PRIVACY_SECTIONS_PL: LegalSection[] = [
205
205
  {
206
206
  kind: 'ul',
207
207
  items: [
208
- { text: '**Pliki cookie ściśle niezbędne:** wymagane do obsługi sesji uwierzytelniania i podstawowych funkcji platformy. Nie można ich wyłączyć bez zakłócenia działania Usługi. Podstawa prawna: art. 6 ust. 1 lit. b, wykonanie umowy; zgoda nie jest wymagana. Czas trwania: sesyjne pliki cookie wygasają po zamknięciu przeglądarki; uwierzytelniające pliki cookie wygasają po 30 dniach bezczynności.' },
208
+ { text: '**Pliki cookie ściśle niezbędne i równoważna pamięć urządzenia:** wymagane do obsługi sesji uwierzytelniania i podstawowych funkcji platformy. Nie można ich wyłączyć bez zakłócenia działania Usługi. Podstawa prawna: art. 6 ust. 1 lit. b, wykonanie umowy; zgoda nie jest wymagana. Token Twojej sesji przechowujemy w pamięci lokalnej przeglądarki, a nie w pliku cookie; plik cookie służy wyłącznie jako rozwiązanie zapasowe tam, gdzie pamięć lokalna jest niedostępna (na przykład w trybie prywatnym). Czas trwania: Twoja sesja wygasa po 30 dniach bezczynności. Przy każdym otwarciu aplikacji te 30 dni liczone jest od nowa, a jeżeli nie wrócisz w ciągu 30 dni, nastąpi wylogowanie.' },
209
209
  { text: '**Funkcjonalne pliki cookie:** ustawiane wyłącznie w bezpośredniej reakcji na podjętą przez Ciebie czynność (np. wybór języka lub motywu) i ściśle niezbędne do wykonania tej konkretnej, zażądanej przez Ciebie funkcji. Nie śledzą Cię między sesjami poza zachowaniem wybranego przez Ciebie ustawienia. Podstawa prawna: ścisła niezbędność do spełnienia Twojego wyraźnego żądania na podstawie art. 173 Prawa telekomunikacyjnego (ePrivacy); odrębna zgoda nie jest wymagana. Czas trwania: do 12 miesięcy lub do wyczyszczenia przez Ciebie danych przeglądarki.' },
210
210
  { text: '**Analityczne pliki cookie (PostHog):** zbierają dane o zdarzeniach korzystania w postaci spseudonimizowanej (poza przepływami identyfikującymi opisanymi w sekcji 6), umożliwiają nagrywanie sesji (z maskowaniem wartości wpisywanych w pola formularzy; zobacz sekcję 3) i rejestrują raporty o błędach, abyśmy mogli rozumieć i ulepszać sposób korzystania z Usługi. Podstawa prawna: art. 6 ust. 1 lit. a, zgoda. **Zanim dokonasz wyboru w banerze zgody na pliki cookie, wyświetlanym przy pierwszej wizycie, nie ustawiamy żadnych analitycznych plików cookie i nie rejestrujemy żadnych zdarzeń analitycznych.** Jeżeli wyrazisz zgodę, PostHog ustawia własny plik cookie (o nazwie zaczynającej się od `ph_`) w domenie `ad2.app`, ważny do 1 roku, współdzielony między naszą stroną internetową a aplikacją, aby nie pytać Cię o to dwa razy. Jeżeli odmówisz, żaden analityczny plik cookie nie zostanie ustawiony i żadne zdarzenia nie będą zbierane. Dane analityczne są przetwarzane na serwerach PostHog Cloud EU we Frankfurcie w Niemczech (zobacz sekcję 6).' },
211
211
  ],
212
212
  },
213
213
  { kind: 'p', text: 'Zgodę na pliki cookie możesz wycofać lub zaktualizować w każdej chwili poprzez link "Ustawienia cookie" w stopce naszej strony internetowej albo na stronie niniejszej Polityki Prywatności w aplikacji. Wycofanie zgody analitycznej nie wpływa na działanie platformy.' },
214
- { kind: 'subheading', text: 'Pamięć lokalna przeglądarki' },
215
- { kind: 'p', text: 'Poza plikami cookie korzystamy z pamięci lokalnej przeglądarki, aby zachować stan aplikacji między sesjami. Obejmuje to: Twoje preferencje języka i motywu; zbuforowaną kopię poziomu i statusu Twojej subskrypcji (przechowywaną do 30 dni, a następnie unieważnianą); oraz robocze dane o terminach kampanii. Dane z pamięci lokalnej są przechowywane wyłącznie na Twoim urządzeniu i nie są przesyłane na nasze serwery niezależnie od Twojego zwykłego korzystania z Usługi. Są usuwane, gdy wyczyścisz dane przeglądarki lub się wylogujesz.' },
214
+ { kind: 'subheading', text: 'Pamięć lokalna przeglądarki i podręczna kopia na urządzeniu' },
215
+ { kind: 'p', text: 'Poza plikami cookie korzystamy z pamięci lokalnej przeglądarki, aby zachować stan aplikacji między sesjami. Obejmuje to: token Twojej sesji (zobacz „ściśle niezbędne" powyżej); Twoje preferencje języka i motywu; zbuforowaną kopię poziomu i statusu Twojej subskrypcji (przechowywaną do 30 dni, a następnie unieważnianą); oraz robocze dane o terminach kampanii.' },
216
+ { kind: 'p', text: 'Przechowujemy również roboczą kopię danych, które już wcześniej wczytałeś, w pamięci IndexedDB Twojej przeglądarki, aby po ponownym otwarciu aplikacji od razu pokazać Twoje ostatnie ekrany zamiast zostawiać Cię przy animacji ładowania. Kopia ta obejmuje do 50 Twoich najnowszych odpowiedzi z naszego API i może zawierać Twoje posty i wersje robocze, Twoje dane statystyczne, dane połączonych kont oraz Twoją skrzynkę odbiorczą, która zawiera komentarze i wiadomości napisane przez inne osoby pod Twoimi postami w mediach społecznościowych. Ogranicza się do danych, do których Twoje konto i tak ma dostęp, jest przypisana do zalogowanego konta, więc inny użytkownik logujący się na tym samym urządzeniu nie może jej odczytać, i jest usuwana przy wylogowaniu, po zakończeniu sesji oraz po wyczyszczeniu danych przeglądarki.' },
217
+ { kind: 'p', text: 'Wszystkie powyższe dane są przechowywane wyłącznie na Twoim urządzeniu i nie są przesyłane na nasze serwery niezależnie od Twojego zwykłego korzystania z Usługi. Możesz je w każdej chwili usunąć, wylogowując się lub czyszcząc dane przeglądarki.' },
216
218
  ],
217
219
  },
218
220
  {
@@ -193,14 +193,16 @@ export const PRIVACY_SECTIONS: LegalSection[] = [
193
193
  {
194
194
  kind: 'ul',
195
195
  items: [
196
- { text: '**Strictly necessary cookies:** required for authentication sessions and core platform functionality. Cannot be disabled without breaking the Service. Legal basis: Art. 6(1)(b), contract performance; no consent required. Duration: session cookies expire when you close your browser; authentication cookies expire after 30 days of inactivity.' },
196
+ { text: '**Strictly necessary cookies and equivalent device storage:** required for authentication sessions and core platform functionality. Cannot be disabled without breaking the Service. Legal basis: Art. 6(1)(b), contract performance; no consent required. Your session token is held in your browser\'s local storage rather than in a cookie; a cookie is used only as a fallback where local storage is unavailable (for example private browsing). Duration: your session expires after 30 days of inactivity. Each time you open the app the 30 days start again, and if you do not return within 30 days you are signed out.' },
197
197
  { text: '**Functional cookies:** set only in direct response to an action you take (e.g. selecting a language or theme preference), and strictly necessary to deliver that specific function you have requested. They do not track you across sessions beyond preserving your chosen setting. Legal basis: strictly necessary to fulfil your explicit request under Art. 173 of the Polish Telecommunications Act (ePrivacy); no separate consent required. Duration: up to 12 months, or cleared when you clear your browser data.' },
198
198
  { text: '**Analytics cookies (PostHog):** collect usage event data in pseudonymised form (other than the identifying flows described in Section 6), enable session replay (with form-field values masked; see Section 3), and capture error reports to help us understand and improve how the Service is used. Legal basis: Art. 6(1)(a), consent. **No analytics cookies are set and no analytics events are captured before you make a choice** in the cookie consent banner shown on first visit. If you accept, PostHog sets a first-party cookie (name beginning `ph_`) on the `ad2.app` domain, valid for up to 1 year, shared between our website and the app so you are not asked twice. If you decline, no analytics cookie is set and no events are collected. Analytics data is processed on PostHog Cloud EU servers in Frankfurt, Germany (see Section 6).' },
199
199
  ],
200
200
  },
201
201
  { kind: 'p', text: 'You may withdraw or update your cookie consent at any time via the "Cookie settings" link in the footer of our website, or on this Privacy Policy page in the app. Withdrawing analytics consent does not affect platform functionality.' },
202
- { kind: 'subheading', text: 'Browser local storage' },
203
- { kind: 'p', text: 'In addition to cookies, we use browser local storage to preserve application state between sessions. This includes: your language and theme preferences; a cached copy of your subscription tier and status (retained for up to 30 days then invalidated); and draft campaign deadline data. Local storage data is stored on your device only and is not transmitted to our servers independently of your normal usage. It is cleared when you clear your browser data or log out.' },
202
+ { kind: 'subheading', text: 'Browser local storage and on-device cache' },
203
+ { kind: 'p', text: 'In addition to cookies, we use browser local storage to preserve application state between sessions. This includes: your session token (see "Strictly necessary" above); your language and theme preferences; a cached copy of your subscription tier and status (retained for up to 30 days then invalidated); and draft campaign deadline data.' },
204
+ { kind: 'p', text: 'We also keep a working copy of data you have already loaded in your browser\'s IndexedDB storage, so the app can show your most recent screens immediately when you reopen it instead of leaving you on a loading spinner. This copy holds up to 50 of your most recent responses from our API and can include your posts and drafts, your analytics figures, your connected account details, and your inbox, which contains comments and messages written by other people on your social media posts. It is limited to data your account is already entitled to see, is scoped to the signed-in account so a different user signing in on the same device cannot read it, and is deleted when you sign out, when your session ends, or when you clear your browser data.' },
205
+ { kind: 'p', text: 'All of the above is stored on your device only and is not transmitted to our servers independently of your normal usage. You can remove it at any time by signing out or clearing your browser data.' },
204
206
  ],
205
207
  },
206
208
  {
@@ -2,7 +2,9 @@ import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import {
4
4
  ANALYTICS_SOURCES,
5
+ GATED_METRICS,
5
6
  SchedulingAnalyticsKpiDTO,
7
+ type MetricAvailability,
6
8
  } from './I_SchedulingAnalytics';
7
9
 
8
10
  // AD2-1045 — the analytics partial-failure signal: a KPI response can name
@@ -89,3 +91,58 @@ test('impressionsAvailable/reachAvailable are optional and absent by default (ba
89
91
  assert.equal('impressionsAvailable' in serialized, false);
90
92
  assert.equal('reachAvailable' in serialized, false);
91
93
  });
94
+
95
+ // Spec 111 — per-metric structural availability. The two AD2-1078 flags above
96
+ // only ever described impressions and reach; the capability map the backend
97
+ // now derives covers eight metrics, and it travelled as an undeclared field
98
+ // cast onto the DTO on one side and re-widened on the other until this landed.
99
+
100
+ test('SchedulingAnalyticsKpiDTO carries metricAvailability when provided', () => {
101
+ const kpi = new SchedulingAnalyticsKpiDTO({
102
+ impressions: 10,
103
+ reach: 5,
104
+ engagements: 4,
105
+ views: 20,
106
+ engagementRate: 1.2,
107
+ followerGrowth: 3,
108
+ metricAvailability: { reach: false, saves: true, clicks: false },
109
+ });
110
+ assert.equal(kpi.metricAvailability?.reach, false);
111
+ assert.equal(kpi.metricAvailability?.saves, true);
112
+ assert.equal(kpi.metricAvailability?.clicks, false);
113
+ assert.deepEqual(
114
+ JSON.parse(JSON.stringify(kpi)).metricAvailability,
115
+ { reach: false, saves: true, clicks: false },
116
+ );
117
+ });
118
+
119
+ test('metricAvailability is optional and absent by default — absent means UNKNOWN', () => {
120
+ const kpi = new SchedulingAnalyticsKpiDTO({
121
+ impressions: 10,
122
+ reach: 5,
123
+ engagements: 4,
124
+ views: 20,
125
+ engagementRate: 1.2,
126
+ followerGrowth: 3,
127
+ });
128
+ assert.equal(kpi.metricAvailability, undefined);
129
+ assert.equal('metricAvailability' in JSON.parse(JSON.stringify(kpi)), false);
130
+ });
131
+
132
+ test('GATED_METRICS names every metric the capability map gates, and nothing else', () => {
133
+ // engagements is our own roll-up and followerGrowth is a different surface —
134
+ // neither is platform-reported, so neither may appear here.
135
+ assert.deepEqual([...GATED_METRICS], [
136
+ 'impressions',
137
+ 'reach',
138
+ 'likes',
139
+ 'comments',
140
+ 'shares',
141
+ 'saves',
142
+ 'clicks',
143
+ 'views',
144
+ ]);
145
+ const availability: MetricAvailability = {};
146
+ for (const metric of GATED_METRICS) availability[metric] = true;
147
+ assert.equal(Object.keys(availability).length, 8);
148
+ });
@@ -20,6 +20,38 @@ export const ANALYTICS_SOURCES = [
20
20
 
21
21
  export type AnalyticsSource = (typeof ANALYTICS_SOURCES)[number];
22
22
 
23
+ // ── Per-metric structural availability (spec 111) ─────────────────────────────
24
+
25
+ /**
26
+ * The metrics whose visibility is decided by the connected platform mix rather
27
+ * than by the data itself.
28
+ *
29
+ * Not every metric is here. `engagements` is our own roll-up (likes + comments
30
+ * + shares + saves) and `followerGrowth` comes from the follower-stats surface,
31
+ * so neither is platform-reported and neither is gated.
32
+ */
33
+ export const GATED_METRICS = [
34
+ 'impressions',
35
+ 'reach',
36
+ 'likes',
37
+ 'comments',
38
+ 'shares',
39
+ 'saves',
40
+ 'clicks',
41
+ 'views',
42
+ ] as const;
43
+
44
+ export type GatedMetric = (typeof GATED_METRICS)[number];
45
+
46
+ /**
47
+ * Which gated metrics the current platform mix can actually report.
48
+ *
49
+ * An ABSENT entry means UNKNOWN, never unavailable — the producer omits the map
50
+ * when it could not determine the platform mix, and a consumer must fall
51
+ * through to showing the figure rather than withhold on a guess.
52
+ */
53
+ export type MetricAvailability = Partial<Record<GatedMetric, boolean>>;
54
+
23
55
  // ── SchedulingAnalyticsKpiDTO ─────────────────────────────────────────────────
24
56
 
25
57
  /**
@@ -62,6 +94,16 @@ export class SchedulingAnalyticsKpiDTO {
62
94
  * Absent/true = available.
63
95
  */
64
96
  reachAvailable?: boolean;
97
+ /**
98
+ * Structural availability for every gated metric, from the backend's
99
+ * per-platform capability map (spec 111). Supersedes the two flags above,
100
+ * which stay for consumers that only ever read those two.
101
+ *
102
+ * ABSENT means UNKNOWN, not unavailable: the backend omits this whole map
103
+ * when it could not determine the connected platform mix, and a consumer
104
+ * must show the figure rather than withhold on a guess.
105
+ */
106
+ metricAvailability?: MetricAvailability;
65
107
 
66
108
  constructor(data: SchedulingAnalyticsKpiDTO) {
67
109
  this.impressions = data.impressions;
@@ -82,6 +124,9 @@ export class SchedulingAnalyticsKpiDTO {
82
124
  if (data.reachAvailable !== undefined) {
83
125
  this.reachAvailable = data.reachAvailable;
84
126
  }
127
+ if (data.metricAvailability !== undefined) {
128
+ this.metricAvailability = data.metricAvailability;
129
+ }
85
130
  }
86
131
  }
87
132