From 571517bd3a1c1b90796e8ce7fd774f57865b89d2 Mon Sep 17 00:00:00 2001 From: Jeremie Zitti Date: Wed, 12 Aug 2026 10:14:59 +0100 Subject: [PATCH 01/17] feat(tests+front): couverture e2e SKI-49/50/51/52 + pages mentions et preferences email Tests e2e - suite mockee: 167 tests (parcours P26, moderation, badges, notifications, sandbox, forum, tournois, certifications, messages, bounties, diplomes) - backend mock local (tests/e2e/utils/mock-backend.mjs) pour l'auth SSR - barriere d'hydratation + warmup globalSetup contre la flakiness Vite - 21 specs parcours migrees sur les helpers de session, suite verte 89/91 Bugs corriges - /slices/{id}: fetch du load injecte dans createSlicesApi (500 SSR) - rebond login sur 24 pages: seed synchrone de l'identite dans +layout - capabilitiesLoaded jamais vrai pour un anonyme (pages bloquees en skeleton) - moderation communaute: le rejet postait feedback au lieu de reason (422) Pages livrees - /mentions et /settings/email-preferences d'apres docs/SPEC-MENTIONS-EMAIL-PREFERENCES.md, avec lien depuis le hub reglages - les deux specs parcours correspondantes restent en quarantaine tant que les endpoints backend (SKI-286, SKI-287) ne sont pas livres --- .env.example | 24 + .github/workflows/ci.yml | 65 ++- .gitignore | 2 + playwright.config.ts | 86 ++- src/app.css | 12 +- src/hooks.server.ts | 10 +- src/lib/api/attestation.ts | 42 +- src/lib/api/emailPreferences.ts | 44 ++ src/lib/api/guild.ts | 34 +- src/lib/api/mentions.ts | 53 ++ src/lib/api/moderation.ts | 7 +- src/lib/api/profile.ts | 20 + src/lib/api/slices.ts | 28 + src/lib/api/team_marketplace.ts | 6 + src/lib/api/tracks.ts | 57 ++ src/lib/components/badges/EventStamp.svelte | 6 +- src/lib/components/badges/RankChevron.svelte | 7 +- src/lib/components/layout/Navbar.svelte | 4 +- .../components/profile/BadgesSection.svelte | 43 +- src/lib/i18n/en.ts | 130 ++++- src/lib/i18n/fr.ts | 130 ++++- src/lib/i18n/types.ts | 128 ++++ src/lib/stores/auth.svelte.ts | 43 +- src/lib/stores/geo.svelte.ts | 5 +- src/routes/+layout.svelte | 46 +- src/routes/auth/login/+page.svelte | 5 +- src/routes/challenges/onboarding/+page.svelte | 9 +- src/routes/community/curator/+page.svelte | 29 +- src/routes/dashboard/slices/+page.ts | 26 +- src/routes/dashboard/teams/+page.svelte | 95 +++ src/routes/dashboard/tracks/+page.svelte | 92 +++ src/routes/guilds/+page.svelte | 23 +- src/routes/guilds/[slug]/+page.svelte | 202 +++++-- src/routes/mentions/+page.svelte | 142 +++++ src/routes/moderation/plagiarism/+page.svelte | 25 +- src/routes/notifications/+page.svelte | 99 ++-- src/routes/profile/[username]/+page.svelte | 4 +- src/routes/settings/+page.svelte | 107 ++-- .../settings/email-preferences/+page.svelte | 108 ++++ .../settings/notifications/+page.svelte | 24 + src/routes/settings/profile/+page.svelte | 130 +++++ .../profile/availability/+page.svelte | 176 ++++++ src/routes/slices/[id]/+page.ts | 9 +- src/routes/tracks/+page.svelte | 75 +++ src/routes/tracks/[slug]/+page.svelte | 152 +++++ tests/e2e/auth.test.ts | 180 ++++-- tests/e2e/bounty-detail.test.ts | 147 +++++ tests/e2e/certification-attempt.test.ts | 165 ++++++ tests/e2e/challenge-sandbox.test.ts | 239 ++++++++ tests/e2e/challenges.test.ts | 5 +- tests/e2e/diploma-verify.test.ts | 120 ++++ tests/e2e/email-preferences.test.ts | 134 +++++ tests/e2e/enterprise-auth.test.ts | 51 +- tests/e2e/enterprise-passkey-magic.test.ts | 7 +- tests/e2e/enterprise-types.test.ts | 5 +- tests/e2e/events.test.ts | 38 +- tests/e2e/forum-thread.test.ts | 183 ++++++ tests/e2e/i18n.test.ts | 47 +- tests/e2e/landing.test.ts | 13 +- tests/e2e/mentions.test.ts | 166 ++++++ tests/e2e/mentorship-sessions.test.ts | 157 +++++ tests/e2e/messages-thread.test.ts | 159 +++++ tests/e2e/moderation-community.test.ts | 44 +- tests/e2e/moderation-plagiarism.test.ts | 107 ++++ tests/e2e/notifications-workflow.test.ts | 206 +++++++ tests/e2e/onboarding-orientations.test.ts | 15 +- tests/e2e/p26-workflow.test.ts | 548 ++++++++++++++++++ .../parcours/_helpers/enterprise-session.ts | 45 +- tests/e2e/parcours/_helpers/user-session.ts | 72 ++- .../e2e/parcours/auth-forgot-password.spec.ts | 7 + tests/e2e/parcours/auth-invite-token.spec.ts | 4 + tests/e2e/parcours/auth-magic-link.spec.ts | 7 + .../e2e/parcours/auth-reset-password.spec.ts | 7 + tests/e2e/parcours/dashboard-slices.spec.ts | 30 +- tests/e2e/parcours/diplomas-verify.spec.ts | 9 + tests/e2e/parcours/email-preferences.spec.ts | 4 + tests/e2e/parcours/enterprise-setup.spec.ts | 2 +- .../parcours/for-companies-bounties.spec.ts | 10 + tests/e2e/parcours/for-companies.spec.ts | 10 + tests/e2e/parcours/for-maintainers.spec.ts | 10 + tests/e2e/parcours/guild-admin.spec.ts | 4 +- tests/e2e/parcours/guilds-listing.spec.ts | 9 + tests/e2e/parcours/guilds-slug.spec.ts | 20 +- tests/e2e/parcours/mentions.spec.ts | 4 + tests/e2e/parcours/mentors-listing.spec.ts | 9 + .../parcours/moderation-plagiarism.spec.ts | 29 +- .../parcours/onboarding-fresh-user.spec.ts | 15 +- tests/e2e/parcours/pricing.spec.ts | 10 + tests/e2e/parcours/rank-history.spec.ts | 10 +- tests/e2e/parcours/signup-enterprise.spec.ts | 9 +- tests/e2e/parcours/signup-user.spec.ts | 21 +- tests/e2e/parcours/slice-detail.spec.ts | 32 +- tests/e2e/parcours/user-setup.spec.ts | 2 +- tests/e2e/parcours/validations-queue.spec.ts | 26 +- .../parcours/validator-application.spec.ts | 32 +- tests/e2e/parcours/verify-hash.spec.ts | 10 + tests/e2e/profile-badges-gallery.test.ts | 152 +++++ tests/e2e/profile-badges.test.ts | 25 +- tests/e2e/team-marketplace.test.ts | 15 +- tests/e2e/tournament-detail.test.ts | 147 +++++ tests/e2e/utils/a11y.ts | 15 +- tests/e2e/utils/global-setup.ts | 46 ++ tests/e2e/utils/hydration.ts | 18 + tests/e2e/utils/mock-backend.mjs | 103 ++++ tests/e2e/wallet.test.ts | 158 +++-- tests/unit/api-p16-p25.test.ts | 9 +- tests/unit/moderation.test.ts | 8 +- vite.config.ts | 53 +- 108 files changed, 5920 insertions(+), 588 deletions(-) create mode 100644 src/lib/api/emailPreferences.ts create mode 100644 src/lib/api/mentions.ts create mode 100644 src/lib/api/tracks.ts create mode 100644 src/routes/dashboard/teams/+page.svelte create mode 100644 src/routes/dashboard/tracks/+page.svelte create mode 100644 src/routes/mentions/+page.svelte create mode 100644 src/routes/settings/email-preferences/+page.svelte create mode 100644 src/routes/settings/notifications/+page.svelte create mode 100644 src/routes/settings/profile/+page.svelte create mode 100644 src/routes/settings/profile/availability/+page.svelte create mode 100644 src/routes/tracks/+page.svelte create mode 100644 src/routes/tracks/[slug]/+page.svelte create mode 100644 tests/e2e/bounty-detail.test.ts create mode 100644 tests/e2e/certification-attempt.test.ts create mode 100644 tests/e2e/challenge-sandbox.test.ts create mode 100644 tests/e2e/diploma-verify.test.ts create mode 100644 tests/e2e/email-preferences.test.ts create mode 100644 tests/e2e/forum-thread.test.ts create mode 100644 tests/e2e/mentions.test.ts create mode 100644 tests/e2e/mentorship-sessions.test.ts create mode 100644 tests/e2e/messages-thread.test.ts create mode 100644 tests/e2e/moderation-plagiarism.test.ts create mode 100644 tests/e2e/notifications-workflow.test.ts create mode 100644 tests/e2e/p26-workflow.test.ts create mode 100644 tests/e2e/profile-badges-gallery.test.ts create mode 100644 tests/e2e/tournament-detail.test.ts create mode 100644 tests/e2e/utils/global-setup.ts create mode 100644 tests/e2e/utils/hydration.ts create mode 100644 tests/e2e/utils/mock-backend.mjs 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..d531507 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,9 +50,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 +70,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 +83,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/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..23ecbfe 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; } @@ -50,15 +55,30 @@ export const guildApi = { 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); }, 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/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/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/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} + + +