diff --git a/.env.example b/.env.example index db058b9..4d9b737 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,27 @@ PUBLIC_P26_ENABLED=false # PUBLIC_APP_VERSION= # PUBLIC_POSTHOG_KEY= # PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com + +# Comptes e2e (tests/e2e/parcours) — JAMAIS en dur dans le code, ce repo est public. +# Le compte user doit exister cote backend ; sinon le helper le cree via register. +E2E_USER_EMAIL= +E2E_USER_USERNAME= +E2E_USER_PASSWORD= +E2E_USER_FIRST_NAME=Test +E2E_USER_LAST_NAME=User + +# Compte enterprise (TOTP arme automatiquement au premier run). +E2E_ENTERPRISE_EMAIL= +E2E_ENTERPRISE_USERNAME= +E2E_ENTERPRISE_PASSWORD= +E2E_ENTERPRISE_FIRST_NAME=Test +E2E_ENTERPRISE_LAST_NAME=Enterprise +E2E_ENTERPRISE_COMPANY=Test Enterprise + +# --- CI --- +# Le job `e2e-parcours` (.github/workflows/ci.yml) attend ces secrets GitHub : +# E2E_API_BASE_URL (ex. https://api.skill-uv.com) +# E2E_USER_EMAIL / E2E_USER_USERNAME / E2E_USER_PASSWORD +# E2E_ENTERPRISE_EMAIL / E2E_ENTERPRISE_USERNAME / E2E_ENTERPRISE_PASSWORD +# Sans E2E_API_BASE_URL le job echoue volontairement : des specs qui skippent +# toutes ressemblent trop a un run vert. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d479ca5..6468076 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,12 @@ name: CI on: push: - branches: [master, main] + branches: [master, main, prep-prod] pull_request: - branches: [master, main] + # prep-prod is an integration branch that work merges into before it + # reaches main. Without it here, every pull request targeting prep-prod + # ran no check at all and merged unverified. + branches: [master, main, prep-prod] jobs: check: @@ -50,9 +53,11 @@ jobs: name: Playwright end-to-end runs-on: ubuntu-latest needs: check - # Playwright currently requires a running backend (proxied /api/* calls). - # TODO: mock API calls or spin up backend services here. Non-blocking for now. - continue-on-error: true + # Blocking on purpose: the mocked suite no longer needs a real backend. + # Browser calls are intercepted by `page.route` and SSR auth is served by + # tests/e2e/utils/mock-backend.mjs, started as a webServer by + # playwright.config.ts. Do not reintroduce `continue-on-error`: that is what + # let 35 broken tests go unnoticed. steps: - uses: actions/checkout@v7 @@ -68,8 +73,10 @@ jobs: - name: Install Playwright browsers run: npx playwright install --with-deps chromium - - name: Playwright tests (unit + axe-core a11y assertions) - run: npx playwright test + # Mocked suite only: hermetic, no real backend. The parcours specs run in + # their own job below so a backend outage cannot redden this one. + - name: Playwright mocked suite + run: npx playwright test --project=legacy-chromium - name: Upload Playwright report if: failure() @@ -79,6 +86,57 @@ jobs: path: playwright-report/ retention-days: 7 + e2e-parcours: + name: Playwright parcours (real backend) + runs-on: ubuntu-latest + needs: check + # Hits the shared test backend, so it only runs where the secrets exist. + # Skipped on forks rather than failing with empty credentials. + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + env: + PUBLIC_API_BASE_URL: ${{ secrets.E2E_API_BASE_URL }} + API_URL: ${{ secrets.E2E_API_BASE_URL }}/api + E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }} + E2E_USER_USERNAME: ${{ secrets.E2E_USER_USERNAME }} + E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }} + E2E_ENTERPRISE_EMAIL: ${{ secrets.E2E_ENTERPRISE_EMAIL }} + E2E_ENTERPRISE_USERNAME: ${{ secrets.E2E_ENTERPRISE_USERNAME }} + E2E_ENTERPRISE_PASSWORD: ${{ secrets.E2E_ENTERPRISE_PASSWORD }} + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + # Without the backend URL the specs would all skip silently, which reads + # exactly like a green run. Fail loudly instead. + - name: Verify backend configuration + run: | + if [ -z "$PUBLIC_API_BASE_URL" ]; then + echo "E2E_API_BASE_URL secret is not set; parcours specs would all skip." >&2 + exit 1 + fi + + - name: Playwright parcours suite + run: npx playwright test --project=parcours-chromium + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-report-parcours + path: playwright-report/ + retention-days: 7 + lighthouse: name: Lighthouse mobile perf budget runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 4fab43b..af96a85 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ vite.config.ts.timestamp-* # Tests test-results playwright-report +# Playwright storage state + plaintext test-account credentials. +tests/e2e/.auth/ # OS .DS_Store diff --git a/playwright.config.ts b/playwright.config.ts index 91b71fb..2fcbc72 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,9 +1,32 @@ import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; + +// Playwright does not read `.env` (Vite does). Without this the `parcours/` +// specs saw an empty `PUBLIC_API_BASE_URL` and skipped all 91 of themselves +// even though the file was filled in. Existing environment variables win, so CI +// can impose its own. `quiet` keeps dotenv's banner off stdout, which would +// otherwise corrupt `--reporter=json`. +dotenv.config({ quiet: true }); const BASE_URL = process.env.PUBLIC_BASE_URL ?? 'http://localhost:5173'; const IS_SMOKE_ONLY = process.env.SMOKE_ONLY === '1'; const IS_CROSS_BROWSER = process.env.CROSS_BROWSER === '1'; +/** The `parcours/` specs only run when a backend is configured. */ +const HAS_BACK = Boolean(process.env.PUBLIC_API_BASE_URL); + +/** + * Whether this invocation can run `parcours/` specs at all. Starting the dev + * server for a legacy-only run just steals CPU from it and made the mocked + * suite flaky, so gate it on the requested project. + */ +const TARGETS_PARCOURS = + !process.argv.some((a) => a.startsWith('--project=')) || + process.argv.some((a) => a.includes('parcours')); + +/** Mock backend port, kept off 3001 so a real local backend never clashes. */ +const MOCK_BACKEND_PORT = 3099; + /** * Deux univers de tests coexistent : * @@ -26,6 +49,9 @@ const IS_CROSS_BROWSER = process.env.CROSS_BROWSER === '1'; */ export default defineConfig({ testDir: 'tests/e2e', + // Only meaningful when the dev server is started (parcours runs); it returns + // immediately otherwise. + globalSetup: TARGETS_PARCOURS && HAS_BACK ? './tests/e2e/utils/global-setup.ts' : undefined, retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? 'github' : 'list', fullyParallel: true, @@ -38,16 +64,57 @@ export default defineConfig({ trace: process.env.CI ? 'on' : 'on-first-retry', video: process.env.CI ? 'on' : 'retain-on-failure' }, - // Le webServer legacy (build + preview :4173) reste utilise par les projets - // `legacy-*`. Il est desactive quand on ne lance que les smoke/parcours - // (SMOKE_ONLY=1) pour ne pas rebuild inutilement. + /** + * Servers started automatically. + * + * 1. Mock backend on :3099 — serves `GET /api/auth/me` so SSR auth + * (hooks.server.ts) can be simulated in the mocked suite. See + * tests/e2e/utils/mock-backend.mjs. + * + * 2. App on :4173 — adapter-node build, not `vite preview`: only the node + * server actually replays the hooks, cookies included. Its environment is + * pinned to the mock backend so the mocked suite stays hermetic even when + * `.env` points at a remote backend. + * + * 3. Dev server on :5173 — only when `PUBLIC_API_BASE_URL` is set AND the run + * targets the `parcours/` specs. It proxies `/api` to the target backend + * (vite.config.ts). + */ webServer: IS_SMOKE_ONLY ? undefined - : { - command: 'npm run build && npm run preview', - port: 4173, - reuseExistingServer: !process.env.CI - }, + : [ + { + command: 'node tests/e2e/utils/mock-backend.mjs', + port: MOCK_BACKEND_PORT, + env: { MOCK_BACKEND_PORT: String(MOCK_BACKEND_PORT) }, + reuseExistingServer: !process.env.CI + }, + { + command: 'npm run build && node build/index.js', + port: 4173, + env: { + PORT: '4173', + // The mocked suite must never hit the real backend, even when + // `.env` configures one for the parcours specs. + API_URL: `http://localhost:${MOCK_BACKEND_PORT}/api`, + PUBLIC_API_BASE_URL: `http://localhost:${MOCK_BACKEND_PORT}` + }, + reuseExistingServer: !process.env.CI, + // The build runs inside this command: the 60s default was not + // enough and failed the Playwright job before the first test. + timeout: 300_000 + }, + ...(HAS_BACK && TARGETS_PARCOURS + ? [ + { + command: 'npm run dev -- --port 5173', + port: 5173, + reuseExistingServer: true, + timeout: 120_000 + } + ] + : []) + ], projects: [ { name: 'legacy-chromium', @@ -79,6 +146,9 @@ export default defineConfig({ // Sans back, chaque test skip proprement via test.skip(!HAS_BACK). name: 'parcours-chromium', testMatch: 'parcours/**/*.spec.ts', + // These specs cross the network to a real backend, so they need more + // headroom than the mocked suite. + timeout: 90_000, use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 }, diff --git a/src/app.css b/src/app.css index 8730caf..a950380 100644 --- a/src/app.css +++ b/src/app.css @@ -200,7 +200,7 @@ --sk-border-strong: #7a6350; --sk-text: #f4ede0; /* crème patinée */ - --sk-text-muted: #b8a68a; + --sk-text-muted: #c2b195; /* AA: 4.99:1 on surface-overlay (was 4.42) */ /* Surfaces catégorielles — zones colorées assumées (Persona-style) */ --sk-surface-craft: #3a2510; /* ambre profond */ @@ -219,8 +219,8 @@ --sk-accent-fg: #18130f; --sk-shadow-color: rgba(0, 0, 0, 0.5); - --sk-success: #2a9d8f; - --sk-success-soft: rgba(42, 157, 143, 0.2); + --sk-success: #32b8ab; /* AA: 4.72:1 on bg-success/15 (was 3.66) */ + --sk-success-soft: rgba(50, 184, 171, 0.2); --sk-warning: #e9c46a; --sk-warning-soft: rgba(233, 196, 106, 0.2); --sk-error: #c1272d; /* rouge (assombri pour contrast WCAG AA >= 4.5 avec text-white) */ @@ -283,7 +283,7 @@ --sk-border-strong: #5a6a95; --sk-text: #f4e8c8; - --sk-text-muted: #9d8865; + --sk-text-muted: #beb098; /* AA: 4.70:1 on surface-overlay (was 2.93) */ --sk-surface-craft: #2f2618; --sk-surface-create: #38141a; @@ -380,8 +380,8 @@ --sk-accent-fg: #f4ede0; --sk-shadow-color: rgba(0, 0, 0, 0.7); - --sk-success: #2a9d8f; - --sk-success-soft: rgba(42, 157, 143, 0.2); + --sk-success: #32b8ab; /* AA: 4.72:1 on bg-success/15 (was 3.66) */ + --sk-success-soft: rgba(50, 184, 171, 0.2); --sk-warning: #e9c46a; --sk-warning-soft: rgba(233, 196, 106, 0.2); --sk-error: #ea580c; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index a30ad2c..50d3689 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,6 +1,7 @@ import type { Handle, HandleServerError } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; +import { env as publicEnv } from '$env/dynamic/public'; import type { UserPrivate } from '$lib/types'; /** @@ -59,7 +60,14 @@ export const handle: Handle = async ({ event, resolve }) => { if (accessToken) { try { - const apiUrl = env.API_URL ?? 'http://localhost:3001/api'; + // `PUBLIC_API_BASE_URL` drives both the dev server proxy + // (vite.config.ts) and this SSR call. `API_URL` still wins for + // deployments that need an internal URL distinct from the public one. + const apiUrl = + env.API_URL ?? + (publicEnv.PUBLIC_API_BASE_URL + ? `${publicEnv.PUBLIC_API_BASE_URL.replace(/\/+$/, '')}/api` + : 'http://localhost:3001/api'); const response = await fetch(`${apiUrl}/auth/me`, { headers: { Cookie: `access_token=${accessToken}` diff --git a/src/lib/api/attestation.ts b/src/lib/api/attestation.ts index 18097d9..14e3aa1 100644 --- a/src/lib/api/attestation.ts +++ b/src/lib/api/attestation.ts @@ -1,3 +1,4 @@ +import { env } from '$env/dynamic/public'; import { createApiClient } from './client'; // --- Types (P26 v2 attestation publique) --- @@ -29,27 +30,48 @@ export interface AttestationInvalid { export type AttestationResponse = AttestationValid | AttestationInvalid; -// SKI-115 endpoint hors /api (public verify) -const publicApi = createApiClient(fetch, ''); +const api = createApiClient(); + +/** + * Origin of the backend, without the `/api` prefix. + * + * The PDF and the badge SVGs are served from the backend ROOT, not under + * `/api`. Referencing them with a relative path resolved them against the + * frontend origin instead, where `/badge/*` does not exist and `/verify/*` is + * taken by the verification page itself — so every badge and every PDF link was + * broken. They are consumed as `href` / `src`, never fetched, so an absolute + * cross-origin URL needs no CORS. + */ +function backendOrigin(): string { + return (env.PUBLIC_API_BASE_URL ?? '').replace(/\/+$/, ''); +} export const attestationApi = { - // GET /verify/{hash} — retourne le JSON attestation + /** + * Verification payload. + * + * Goes through `/api` so it stays same-origin behind the existing proxy. + * See SKI-288: the backend also serves this at its root, but that path + * collides with this app's own `/verify/[hash]` page. + */ verify(hash: string) { - return publicApi.get(`/verify/${encodeURIComponent(hash)}`); + // Bare payload, not the `{ data, meta }` envelope: this route mirrors the + // public root endpoint, which returns the object directly. + return api.get(`/verify/${encodeURIComponent(hash)}`); }, - // URL directe du PDF (deep link, pas de fetch cote front) + /** Direct PDF link (SKI-118). */ pdfUrl(hash: string): string { - return `/verify/${encodeURIComponent(hash)}.pdf`; + return `${backendOrigin()}/verify/${encodeURIComponent(hash)}.pdf`; }, - // SKI-116 badge user SVG (URL directe) + /** User badge SVG (SKI-116). */ badgeUserUrl(username: string): string { - return `/badge/user/${encodeURIComponent(username)}/validated.svg`; + return `${backendOrigin()}/badge/user/${encodeURIComponent(username)}/validated.svg`; }, - // SKI-117 badge repo SVG (URL directe) + /** Repo badge SVG (SKI-117). */ badgeRepoUrl(owner: string, name: string): string { - return `/badge/repo/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/validated.svg`; + return `${backendOrigin()}/badge/repo/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/validated.svg`; } }; diff --git a/src/lib/api/certifications.ts b/src/lib/api/certifications.ts index 9aba95f..ee5c376 100644 --- a/src/lib/api/certifications.ts +++ b/src/lib/api/certifications.ts @@ -25,6 +25,8 @@ export interface Certification { export interface PurchaseResponse { attempt_id: string; checkout_url?: string; + /** Our identifier for the charge, when a payment was opened. */ + payment_id?: string; session_id?: string; status?: string; message?: string; diff --git a/src/lib/api/credits.ts b/src/lib/api/credits.ts index 75cc040..44f2fc6 100644 --- a/src/lib/api/credits.ts +++ b/src/lib/api/credits.ts @@ -30,6 +30,8 @@ export interface CreditTransaction { export interface CheckoutSessionResponse { session_id: string; checkout_url: string; + /** Our identifier for the charge. */ + payment_id: string; } export interface PromoRedeemResult { diff --git a/src/lib/api/disputes.ts b/src/lib/api/disputes.ts new file mode 100644 index 0000000..d241c0c --- /dev/null +++ b/src/lib/api/disputes.ts @@ -0,0 +1,66 @@ +import type { ApiResponse } from '$lib/types'; +import { createApiClient } from './client'; + +const api = createApiClient(); + +/** Which side of a dispute the caller is on. The two have different moves. */ +export type DisputeRole = 'payer' | 'recipient'; + +/** + * A frozen payment, and the account each side gives of it. + * + * `open` means the recipient has not answered yet; `contested` means they + * disagreed and an operator has to decide; `refunded` and `released` are + * the two ends. + */ +export interface Dispute { + id: string; + status: 'open' | 'contested' | 'refunded' | 'released' | 'withdrawn'; + /** What the payer says went wrong, in their own words. */ + reason: string; + /** The recipient's account, once they have contested. */ + recipient_response: string | null; + /** What the operator decided, and why. Both sides read it. */ + resolution_note: string | null; + /** What the payment was for: `mentorship_session`, `bounty_slice`. */ + subject_type: string; + subject_id: string; + /** Decimal string. Never a float — money does not survive one. */ + amount: string; + currency: string; + created_at: string; + resolved_at: string | null; + viewer_role: DisputeRole; +} + +export const disputesApi = { + /** GET /disputes — every dispute the caller is party to, either side. */ + list() { + return api.get>('/disputes'); + }, + + /** + * POST /disputes — freeze the payment and ask the recipient to answer. + * + * Only the person who paid can do this, and only inside the release + * window: past it the money has already gone. + */ + raise(body: { subject_type: string; subject_id: string; reason: string }) { + return api.post>('/disputes', body); + }, + + /** POST /disputes/{id}/concede — the recipient agrees; the payer is refunded. */ + concede(id: string) { + return api.post>(`/disputes/${id}/concede`); + }, + + /** POST /disputes/{id}/contest — the recipient disagrees; a human decides. */ + contest(id: string, response: string) { + return api.post>(`/disputes/${id}/contest`, { response }); + }, + + /** POST /disputes/{id}/withdraw — the payer drops it; the money is released. */ + withdraw(id: string) { + return api.post>(`/disputes/${id}/withdraw`); + } +}; diff --git a/src/lib/api/emailPreferences.ts b/src/lib/api/emailPreferences.ts new file mode 100644 index 0000000..54a0956 --- /dev/null +++ b/src/lib/api/emailPreferences.ts @@ -0,0 +1,44 @@ +import type { ApiResponse } from '$lib/types'; +import { createApiClient } from './client'; + +const api = createApiClient(); + +/** + * Opt-in/opt-out email categories. Transactional mail (address verification, + * password reset, security alerts, payment receipts) is never listed here and + * cannot be disabled. + * + * Contract: docs/SPEC-MENTIONS-EMAIL-PREFERENCES.md section 2. + */ +export interface EmailPreferences { + digest_weekly: boolean; + streak_reminder: boolean; + marketing: boolean; + updated_at?: string; +} + +/** Full replacement, not a partial patch: all three booleans are required. */ +export interface UpdateEmailPreferencesBody { + digest_weekly: boolean; + streak_reminder: boolean; + marketing: boolean; +} + +/** Defaults served when the user has never touched their preferences. */ +export const EMAIL_PREFERENCE_DEFAULTS: UpdateEmailPreferencesBody = { + digest_weekly: true, + streak_reminder: true, + marketing: false +}; + +export const emailPreferencesApi = { + /** GET /users/me/email-preferences */ + get(): Promise> { + return api.get>('/users/me/email-preferences'); + }, + + /** PUT /users/me/email-preferences */ + update(body: UpdateEmailPreferencesBody): Promise> { + return api.put>('/users/me/email-preferences', body); + } +}; diff --git a/src/lib/api/guild.ts b/src/lib/api/guild.ts index b407f79..b3eb3c2 100644 --- a/src/lib/api/guild.ts +++ b/src/lib/api/guild.ts @@ -12,11 +12,16 @@ export interface Guild { tag: string; description: string | null; logo_url: string | null; - color_hex: string | null; - member_count: number; - total_fragments: number; - total_wars_won: number; - total_wars_lost: number; + /** The list endpoint returns `color_hex`, the detail one `color_primary`. */ + color_hex?: string | null; + color_primary?: string | null; + // The detail endpoint (`GET /guilds/{slug}`) omits these aggregates; only + // the list endpoint returns them. Optional so the detail page cannot crash + // on a missing counter. + member_count?: number; + total_fragments?: number; + total_wars_won?: number; + total_wars_lost?: number; rank?: number; created_at: string; } @@ -45,20 +50,63 @@ export interface GuildWar { // --- API --- +/** A user named in an application or an invitation. */ +export interface GuildUserRef { + id: string; + username: string | null; + display_name: string | null; +} + +/** One pending application to join a guild. Decided ones drop out of the list. */ +export interface GuildApplication { + id: string; + applicant: GuildUserRef; + status: string; + applied_at: string; + message: string | null; +} + +/** + * One pending invitation. Exactly one of `invitee` / `token` is set: a direct + * invitation names a user, a shareable link carries a token. + */ +export interface GuildInvitation { + id: string; + invitee: GuildUserRef | null; + token: { value: string } | null; + sent_at: string; + expires_at: string; +} + export const guildApi = { leaderboard(params?: { page?: number; per_page?: number }) { return api.get>('/guilds', params as Record); }, + /** The backend wraps the guild in `data.guild`, unlike the list endpoint. */ getBySlug(slug: string) { - return api.get>(`/guilds/${slug}`); + return api.get>(`/guilds/${slug}`); }, members(guildId: string) { return api.get>(`/guilds/${guildId}/members`); }, - create(data: { name: string; tag: string; description?: string; color_hex?: string }) { + /** + * Mint a guild. + * + * The backend requires `slug` and exactly three `cofounder_ids` — a guild + * cannot be founded alone. The previous signature omitted both and would + * always fail with 422. There is no creation page yet; see SKI-289. + */ + create(data: { + name: string; + slug: string; + tag: string; + cofounder_ids: [string, string, string]; + description?: string; + color_hex?: string; + }) { return api.post>('/guilds', data); }, @@ -70,6 +118,41 @@ export const guildApi = { return api.post>('/guilds/join-by-token', { token }); }, + /** GET /guilds/{id}/applications — owner/officer only. */ + applications(guildId: string) { + return api.get>( + `/guilds/${guildId}/applications` + ); + }, + + /** + * POST /guild-applications/{id}/decide — accept or reject a candidate. + * + * The body is `{ accept }`. The OpenAPI leaves this request untyped, so the + * field name was confirmed against the test backend: anything else comes + * back as `missing field \`accept\``. + */ + decideApplication(applicationId: string, accept: boolean) { + return api.post>( + `/guild-applications/${applicationId}/decide`, + { accept } + ); + }, + + /** GET /guilds/{id}/invitations — owner/officer only. */ + invitations(guildId: string) { + return api.get>( + `/guilds/${guildId}/invitations` + ); + }, + + /** DELETE /guilds/{id}/invitations/{invitationId} — idempotent (SKI-289). */ + revokeInvitation(guildId: string, invitationId: string) { + return api.delete>( + `/guilds/${guildId}/invitations/${invitationId}` + ); + }, + apply(guildId: string, message?: string) { return api.post>(`/guilds/${guildId}/applications`, { message }); }, diff --git a/src/lib/api/mentions.ts b/src/lib/api/mentions.ts new file mode 100644 index 0000000..8ac079c --- /dev/null +++ b/src/lib/api/mentions.ts @@ -0,0 +1,53 @@ +import type { ApiPaginatedResponse, ApiResponse } from '$lib/types'; +import { createApiClient } from './client'; + +const api = createApiClient(); + +/** Surface a mention was written on. */ +export type MentionSourceType = 'forum_post' | 'comment' | 'slice_diary' | 'message'; + +/** + * One place where the current user was cited with @username. + * + * Contract: docs/SPEC-MENTIONS-EMAIL-PREFERENCES.md section 1. + */ +export interface Mention { + id: string; + source_type: MentionSourceType; + source_id: string; + /** Front-end path built by the backend, ready to link to as-is. */ + source_url: string; + /** Plain-text excerpt around the mention, already truncated by the backend. */ + excerpt: string; + author: { + user_id: string; + username: string; + display_name: string; + avatar_url: string | null; + }; + read_at: string | null; + created_at: string; +} + +export const mentionsApi = { + /** GET /users/me/mentions */ + list(params?: { + page?: number; + per_page?: number; + unread_only?: boolean; + }): Promise> { + return api.get>('/users/me/mentions', params); + }, + + /** POST /users/me/mentions/{id}/read — idempotent. */ + markRead(id: string): Promise> { + return api.post>( + `/users/me/mentions/${id}/read` + ); + }, + + /** POST /users/me/mentions/read-all */ + markAllRead(): Promise> { + return api.post>('/users/me/mentions/read-all'); + } +}; diff --git a/src/lib/api/mentorship.ts b/src/lib/api/mentorship.ts index 6e2c77b..ecd8138 100644 --- a/src/lib/api/mentorship.ts +++ b/src/lib/api/mentorship.ts @@ -54,6 +54,12 @@ export interface MentorshipSession { export interface BookResponse { session_id: string; checkout_url: string; + /** + * Our identifier for the charge. Needed to pay without leaving the + * page: pushing the operator prompt and asking where the payment got to + * are both keyed on it. + */ + payment_id: string; price_total_cents: number; mentor_share_cents: number; platform_share_cents: number; diff --git a/src/lib/api/moderation.ts b/src/lib/api/moderation.ts index 6b9bab7..9eb0e32 100644 --- a/src/lib/api/moderation.ts +++ b/src/lib/api/moderation.ts @@ -31,7 +31,12 @@ export interface MuteRecord { /** Body de POST /community/challenges/{id}/reject. * Backend key is `feedback` (min 8 chars), pas `reason`. */ export interface CommunityRejectBody { - feedback: string; + /** + * The backend field is `reason`, not `feedback`. Sending `feedback` failed + * with 422 `missing field reason`, so rejecting a community challenge was + * impossible. + */ + reason: string; } /** Body de POST /fraud/deliverables/{id}/mark-valid | revoke. */ diff --git a/src/lib/api/notificationPreferences.ts b/src/lib/api/notificationPreferences.ts new file mode 100644 index 0000000..4e50802 --- /dev/null +++ b/src/lib/api/notificationPreferences.ts @@ -0,0 +1,116 @@ +import type { ApiResponse } from '$lib/types'; +import { createApiClient } from './client'; + +const api = createApiClient(); + +/** The three channels, in the order the screen presents them. */ +export const CHANNELS = ['in_app', 'push', 'email'] as const; +export type Channel = (typeof CHANNELS)[number]; + +/** + * One notification kind, with the caller's effective settings. + * + * "Effective" means: their stored choice, else the catalogue default. The + * backend merges the two, so the screen never has to tell "never touched" + * apart from "deliberately left as is". + */ +export interface KindPreference { + /** Dotted identifier: `social.mention`, `payout.sent`. */ + kind: string; + /** Display grouping: `payments`, `social`, `guild`. */ + category: string; + /** Title, translated into the caller's language. */ + label: string; + /** + * Channels this kind can use. A channel missing from here cannot be + * turned on whatever the request says, so the screen must not show a + * toggle for it. + */ + available_channels: Channel[]; + in_app: boolean; + push: boolean; + email: boolean; + /** + * Cannot be turned off. Show it as fixed rather than as a toggle that + * springs back: a failed transfer goes out regardless, and pretending + * otherwise is a lie. + */ + transactional: boolean; +} + +/** Partial update: only the channels supplied are touched. */ +export interface PreferenceUpdate { + kind: string; + in_app?: boolean; + push?: boolean; + email?: boolean; +} + +export interface UpdateResult { + updated: number; + /** + * Rejected, with the reason. The backend reports them instead of + * ignoring them: a screen showing a toggle move when the server did + * nothing is worse than an error. + */ + rejected: string[]; +} + +/** Quiet window. Both bounds or neither, and a timezone with them. */ +export interface QuietHours { + start: number | null; + end: number | null; + /** IANA name, e.g. `Africa/Porto-Novo`. */ + timezone: string | null; +} + +export const notificationPreferencesApi = { + /** + * GET /users/me/notification-preferences + * + * Also returns the quiet window. It travels here rather than on an + * endpoint of its own: it could be written and never read back, so the + * screen started from the defaults and overwrote the person's choice on + * the first save. + */ + list(): Promise<{ data: { preferences: KindPreference[]; quiet_hours: QuietHours } }> { + return api.get<{ data: { preferences: KindPreference[]; quiet_hours: QuietHours } }>( + '/users/me/notification-preferences' + ); + }, + + /** PUT /users/me/notification-preferences */ + update(preferences: PreferenceUpdate[]): Promise<{ data: UpdateResult }> { + return api.put<{ data: UpdateResult }>('/users/me/notification-preferences', { + preferences + }); + }, + + /** + * PUT /users/me/notification-preferences/reset — back to defaults. + * + * The backend deletes the overrides instead of writing the defaults: + * the absence of a row *is* the default, and a row storing a default + * can no longer be told apart from a deliberate choice. + */ + reset(): Promise> { + return api.put>( + '/users/me/notification-preferences/reset', + {} + ); + }, + + /** + * PUT /users/me/quiet-hours + * + * `start` and `end` both `null` clear the window. The timezone survives + * that: it belongs to the person, not to the window. + */ + setQuietHours(body: { + start: number | null; + end: number | null; + timezone: string | null; + }): Promise> { + return api.put>('/users/me/quiet-hours', body); + } +}; diff --git a/src/lib/api/payments.ts b/src/lib/api/payments.ts new file mode 100644 index 0000000..2aa9b77 --- /dev/null +++ b/src/lib/api/payments.ts @@ -0,0 +1,130 @@ +import type { ApiResponse } from '$lib/types'; +import { createApiClient } from './client'; + +const api = createApiClient(); + +/** A way to pay, as the backend offers it in this country. */ +export interface PaymentMethod { + /** Stable identifier, sent back to start the payment. */ + operator: string; + /** What to show the payer. Operators rename themselves. */ + label: string; + /** + * True when the payer confirms on their phone without leaving the page. + * False when they have to be sent to the provider's own form. + */ + supports_inline: boolean; + provider: string; +} + +/** Where a payment stands, as of the call. */ +export interface PaymentStatus { + /** `pending`, `succeeded`, `failed`, `refunded`. */ + status: string; + /** + * How long to wait before asking again. + * + * Sent by the backend rather than guessed here: the right interval is a + * property of what it does with the question, and a client that guesses + * too low is exactly what gets the merchant account rate-limited. + */ + poll_after_ms: number; + /** True once the counterparty was delivered, not merely charged. */ + delivered: boolean; +} + +export const paymentsApi = { + /** + * GET /payments/methods — what this payer can use. + * + * Without `country`, the backend uses the one on the account. That is + * deliberate: the profile only exposes the country's name, not its + * code, so having the client map it back would be wrong for every + * country spelled two ways. + */ + methods(country?: string, currency?: string) { + return api.get>('/payments/methods', { + ...(country ? { country } : {}), + ...(currency ? { currency } : {}) + }); + }, + + /** + * POST /payments/{id}/charge — push the prompt to the payer's phone. + * + * Confirms nothing: the request returns as soon as the prompt is sent. + * The payment is confirmed by webhook or by `status`, and closing the + * page does not stop either. + */ + charge(id: string, body: { operator: string; phone?: string }) { + return api.post>( + `/payments/${id}/charge`, + body + ); + }, + + /** GET /payments/{id}/status — where the payment got to. */ + status(id: string) { + return api.get>(`/payments/${id}/status`); + } +}; + +/** What the wait produced, once we stop watching. */ +export interface SettlementOutcome { + /** Last known state. `pending` when we stopped before the answer. */ + status: string; + delivered: boolean; + /** + * True when we stopped waiting without an answer. + * + * This is not a failure, and the screen must not present it as one: the + * backend keeps asking the provider and keeps delivering, whether this + * page is open or not. + */ + gaveUp: boolean; +} + +/** + * Ask the backend until the payment leaves `pending`. + * + * The cadence comes from the backend, not from here: it is the side that + * knows how often it can question the provider without being throttled. + * + * `sleep` is injectable for tests; nothing else should pass it. + */ +export async function waitForSettlement( + id: string, + options: { + onTick?: (status: PaymentStatus) => void; + /** Past this, we hand back control. The backend carries on. */ + timeoutMs?: number; + sleep?: (ms: number) => Promise; + /** Once true, stop — the person closed the window. */ + cancelled?: () => boolean; + } = {} +): Promise { + const { + onTick, + timeoutMs = 5 * 60 * 1000, + sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)), + cancelled = () => false + } = options; + + const deadline = Date.now() + timeoutMs; + let last: PaymentStatus = { status: 'pending', poll_after_ms: 3000, delivered: false }; + + while (!cancelled()) { + const res = await paymentsApi.status(id); + last = res.data; + onTick?.(last); + if (last.status !== 'pending') { + return { status: last.status, delivered: last.delivered, gaveUp: false }; + } + if (Date.now() >= deadline) break; + // Never under a second, even if the backend sent zero: a loop with + // no pause would run into our own rate limits. + await sleep(Math.max(1000, last.poll_after_ms)); + } + + return { status: last.status, delivered: last.delivered, gaveUp: true }; +} diff --git a/src/lib/api/profile.ts b/src/lib/api/profile.ts index 0e790c1..f2a2c15 100644 --- a/src/lib/api/profile.ts +++ b/src/lib/api/profile.ts @@ -24,6 +24,16 @@ interface PublicProfileResponse { }; } +export type SalaryVisibility = 'private' | 'recruiters' | 'public'; + +export interface ProfileAvailability { + available_for_hire: boolean; + looking_for: string | null; + salary_range_min_eur: number | null; + salary_range_max_eur: number | null; + salary_visibility: SalaryVisibility; +} + interface SkillTreeResponse { data: { user: { id: string; display_name: string; title: string; golden_stars: number; total_fragments: number }; @@ -49,6 +59,16 @@ export const profileApi = { return api.put>('/profile/me', data); }, + /** GET /profile/me/availability — hiring availability + salary expectations. */ + getAvailability() { + return api.get>('/profile/me/availability'); + }, + + /** PUT /profile/me/availability */ + updateAvailability(data: ProfileAvailability) { + return api.put>('/profile/me/availability', data); + }, + /** Upload avatar */ uploadAvatar(file: File) { const formData = new FormData(); diff --git a/src/lib/api/slices.ts b/src/lib/api/slices.ts index 8ec052d..10b2fa8 100644 --- a/src/lib/api/slices.ts +++ b/src/lib/api/slices.ts @@ -3,6 +3,34 @@ import { createApiClient } from './client'; const api = createApiClient(); +/** + * Injectable variant for use inside universal `load` functions. + * + * The default client captures the global `fetch`. During SSR a relative URL + * like `/api/slices/{id}` has no base and throws `TypeError: Failed to parse + * URL`. The `fetch` from SvelteKit's load event resolves relative URLs against + * the incoming request and forwards session cookies. + */ +export function createSlicesApi(customFetch: typeof fetch) { + const scoped = createApiClient(customFetch); + return { + get(id: string) { + return scoped.get>(`/slices/${id}`); + }, + mySlices(params?: { status?: SliceStatus; page?: number; per_page?: number }) { + return scoped.get>( + '/users/me/slices', + params as Record + ); + }, + feedRecommended(limit = 20) { + return scoped.get< + ApiResponse<{ slices: Slice[]; meta?: { user_rank_ord?: number; median_difficulty?: number } }> + >('/me/feed/challenges', { limit }); + } + }; +} + // --- Types (P26 v2 workflow challenge) --- export type SliceStatus = diff --git a/src/lib/api/team_marketplace.ts b/src/lib/api/team_marketplace.ts index 22579c4..411cda2 100644 --- a/src/lib/api/team_marketplace.ts +++ b/src/lib/api/team_marketplace.ts @@ -1,6 +1,7 @@ import type { ApiPaginatedResponse, ApiResponse, + Team, TeamMarketplaceSlot, TeamRoleSlot } from '$lib/types'; @@ -26,6 +27,11 @@ export interface CreateSlotBody { } export const teamMarketplaceApi = { + /** GET /users/me/teams — teams the current user belongs to. */ + myTeams() { + return api.get>('/users/me/teams'); + }, + marketplace(filters?: MarketplaceFilters) { return api.get>('/teams/marketplace', filters); }, diff --git a/src/lib/api/tracks.ts b/src/lib/api/tracks.ts new file mode 100644 index 0000000..8a2407a --- /dev/null +++ b/src/lib/api/tracks.ts @@ -0,0 +1,57 @@ +import type { ApiResponse } from '$lib/types'; +import { createApiClient } from './client'; + +const api = createApiClient(); + +/** A learning track as returned by `GET /tracks`. */ +export interface Track { + id: string; + slug: string; + name: string; + description: string | null; + target_domain: string; + target_phase: string; + estimated_hours: number | null; + active: boolean; + created_at: string; + updated_at: string; +} + +/** + * An enrolment, as returned by `GET /users/me/tracks`. + * + * Note the field names differ from `Track`: the backend returns `title` here + * and `name` there, and identifies the track by `track_id`. + */ +export interface UserTrack { + track_id: string; + slug: string; + title: string; + started_at: string; + completed_at: string | null; + current_challenge_id: string | null; +} + +export const tracksApi = { + /** GET /tracks — public catalogue. */ + list() { + return api.get>('/tracks'); + }, + + /** GET /tracks/{slug} */ + getBySlug(slug: string) { + return api.get>(`/tracks/${encodeURIComponent(slug)}`); + }, + + /** GET /users/me/tracks — the current user's enrolments. */ + mine() { + return api.get>('/users/me/tracks'); + }, + + /** POST /tracks/{slug}/enroll — idempotent, re-enrolling returns the existing row. */ + enroll(slug: string) { + return api.post>( + `/tracks/${encodeURIComponent(slug)}/enroll` + ); + } +}; diff --git a/src/lib/api/wallet.ts b/src/lib/api/wallet.ts index a8387b8..fdc154b 100644 --- a/src/lib/api/wallet.ts +++ b/src/lib/api/wallet.ts @@ -10,17 +10,37 @@ export interface StripeOnboardResponse { expires_at: string; } -/** Body de POST /users/me/wallet/withdraw/stripe. */ -export interface StripeWithdrawBody { - /** Montant en devise (pas en cents). Ex "12.50". */ +/** + * Body de POST /users/me/wallet/withdraw. + * + * Un seul endpoint pour tous les rails. Il y en avait deux — + * `/withdraw/stripe` et `/withdraw/momo` — que ce client appelait encore + * alors qu'ils n'existent plus côté backend : le retrait était cassé. + * + * Le rail n'est pas un choix du client. Quel opérateur atteint quelqu'un + * dépend de son pays et de sa devise ; c'est une question de routage que + * le backend tranche avec `payout_routes`. On envoie un montant. + */ +export interface WithdrawBody { + /** Montant en devise, pas en centimes. Ex "12.50" EUR, "5000" XOF. */ amount: string; - currency?: 'EUR'; + /** Déduite du pays de résidence quand absente. */ + currency?: 'EUR' | 'XOF'; + /** + * Force un rail. À ne renseigner que si l'utilisateur a explicitement + * choisi une destination, jamais pour « aider » le backend. + */ + rail?: 'bank_account' | 'mobile_money'; } -/** Body de POST /users/me/wallet/withdraw/momo. */ -export interface MomoWithdrawBody { +export interface WithdrawResponse { amount: string; - currency?: 'XOF'; + currency: string; + /** Qui a payé, décidé par le routage. À afficher, pas à choisir. */ + provider: string; + reference: string; + /** `pending` sur Mobile Money : accepté, confirmé plus tard. */ + status: 'pending' | 'completed' | 'rejected'; } /** Body de POST /users/me/wallet/momo/phone. */ @@ -30,17 +50,6 @@ export interface MomoRegisterBody { provider?: 'orange' | 'mtn' | 'wave'; } -export interface StripeWithdrawResponse { - transaction_id: string; - stripe_transfer_id: string; - amount_cents: number; -} - -export interface MomoWithdrawResponse { - transaction_id: string; - momo_reference: string; -} - export const walletApi = { /** GET /users/me/wallet — balances EUR/XOF + statuts providers. */ get() { @@ -67,12 +76,15 @@ export const walletApi = { }); }, - /** POST /users/me/wallet/withdraw/stripe — nécessite stripe_kyc_status='verified'. */ - stripeWithdraw(body: StripeWithdrawBody) { - return api.post>( - '/users/me/wallet/withdraw/stripe', - body - ); + /** + * POST /users/me/wallet/withdraw — un endpoint, tous les rails. + * + * Le montant part du solde `available`, qui ne contient que l'argent + * dont la fenêtre de libération est passée. Les fonds retenus ne sont + * pas retirables, et c'est précisément pourquoi on les retient. + */ + withdraw(body: WithdrawBody) { + return api.post>('/users/me/wallet/withdraw', body); }, /** POST /users/me/wallet/momo/phone — enregistre le numéro Mobile Money. */ @@ -80,11 +92,6 @@ export const walletApi = { return api.post>('/users/me/wallet/momo/phone', body); }, - /** POST /users/me/wallet/withdraw/momo — nécessite momo_phone_verified=true. */ - momoWithdraw(body: MomoWithdrawBody) { - return api.post>('/users/me/wallet/withdraw/momo', body); - }, - /** GET /users/me/wallet/statement.csv — dump audit compliance. */ statementCsvUrl(): string { return '/api/users/me/wallet/statement.csv'; diff --git a/src/lib/components/badges/EventStamp.svelte b/src/lib/components/badges/EventStamp.svelte index 05a991d..cdcaa89 100644 --- a/src/lib/components/badges/EventStamp.svelte +++ b/src/lib/components/badges/EventStamp.svelte @@ -34,8 +34,10 @@ {/if} - {eventName} - {year} + + +