diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..10842a650 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": ["Bash(yarn test:*)", "Bash(find:*)"], + "deny": [], + "ask": [] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index f71f74de1..000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": ["Bash(fi:*)", "Bash(done)", "Bash(yarn test:unit:*)"], - "deny": [], - "ask": [] - } -} diff --git a/.env.example b/.env.example index 90babc952..6daafa44b 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,9 @@ GENERATE_MOCK_DATA_ON_INITIALISE=true # Used only in development builds GENERATE_MOCK_DATA_ON_RESTART=false +# LLM +LLM_OPENAI_API_KEY="" + # Frontend dependencies # NB! If adding more such urls with different hosts on the server and client, please check whether /frontend/src/routes/[[lang=locale]]/api/cache/+server.ts needs to be updated accordingly # Used to reach backend instance from a browser diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index dfd6ab309..c2fdcedb2 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -5,9 +5,9 @@ on: branches: - main paths-ignore: - - '**.md' - - '**/*/.env.example' - - '.env.example' + - "**.md" + - "**/*/.env.example" + - ".env.example" pull_request: types: - opened @@ -17,12 +17,11 @@ on: branches: - main paths-ignore: - - '**.md' - - '**/*/.env.example' - - '.env.example' + - "**.md" + - "**/*/.env.example" + - ".env.example" jobs: - frontend-and-shared-module-validation: runs-on: ubuntu-latest @@ -40,10 +39,10 @@ jobs: with: node-version: 20.18.1 cache: "yarn" - + - name: "Install all dependencies" run: yarn install --frozen-lockfile - + - name: "Build all shared modules" run: yarn build:shared @@ -100,7 +99,7 @@ jobs: - name: "Install all dependencies" run: yarn install --frozen-lockfile - + - name: "Build all shared modules" run: yarn build:shared @@ -134,7 +133,7 @@ jobs: uses: threeal/setup-yarn-action@v2 with: version: 4.6 - + - name: Setup Node.js 20.18.1 uses: actions/setup-node@v4 with: @@ -161,7 +160,7 @@ jobs: run: yarn playwright install - name: "Start OpenVAA" - run: yarn dev + run: yarn dev:start - name: "Collect Docker logs on failure" if: failure() diff --git a/.gitignore b/.gitignore index 35ae3bd9e..fcc6609e3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ !.env.example node_modules +# Claude Code local settings +.claude/*.local.json + # Yarn 4 without Zero-installs .pnp.* .yarn/* @@ -11,4 +14,8 @@ node_modules !.yarn/plugins !.yarn/releases !.yarn/sdks -!.yarn/versions \ No newline at end of file +!.yarn/versions + +# Generated documentation +docs/generated/ +typedoc.frontend.json \ No newline at end of file diff --git a/backend/vaa-strapi/package.json b/backend/vaa-strapi/package.json index adbe7f579..77eee12cd 100644 --- a/backend/vaa-strapi/package.json +++ b/backend/vaa-strapi/package.json @@ -11,7 +11,7 @@ "strapi": "strapi", "test:e2e": "jest --forceExit --detectOpenHandles", "test:unit": "vitest run ./src", - "generate:types": "strapi ts:generate-types" + "generate:types": "strapi ts:generate-types || echo 'Maybe you need to copy the .env file from the project root to backend/vaa-strapi?'" }, "dependencies": { "@aws-sdk/client-ses": "^3.741.0", diff --git a/backend/vaa-strapi/src/api/admin-job/content-types/admin-job/schema.json b/backend/vaa-strapi/src/api/admin-job/content-types/admin-job/schema.json new file mode 100644 index 000000000..0db4488a1 --- /dev/null +++ b/backend/vaa-strapi/src/api/admin-job/content-types/admin-job/schema.json @@ -0,0 +1,59 @@ +{ + "kind": "collectionType", + "collectionName": "admin_jobs", + "info": { + "singularName": "admin-job", + "pluralName": "admin-jobs", + "displayName": "Admin Job", + "description": "" + }, + "options": { + "draftAndPublish": false + }, + "pluginOptions": {}, + "attributes": { + "electionId": { + "type": "string", + "required": true + }, + "jobId": { + "type": "uid", + "required": true + }, + "jobType": { + "type": "string", + "required": true, + "minLength": 1 + }, + "author": { + "type": "email", + "required": true + }, + "endStatus": { + "type": "enumeration", + "enum": [ + "completed", + "failed", + "aborted" + ] + }, + "startTime": { + "type": "datetime" + }, + "endTime": { + "type": "datetime" + }, + "input": { + "type": "json" + }, + "output": { + "type": "json" + }, + "messages": { + "type": "json" + }, + "metadata": { + "type": "json" + } + } +} diff --git a/backend/vaa-strapi/src/api/admin-job/controllers/admin-job.ts b/backend/vaa-strapi/src/api/admin-job/controllers/admin-job.ts new file mode 100644 index 000000000..03452aa70 --- /dev/null +++ b/backend/vaa-strapi/src/api/admin-job/controllers/admin-job.ts @@ -0,0 +1,7 @@ +/** + * admin-job controller + */ + +import { factories } from '@strapi/strapi'; + +export default factories.createCoreController('api::admin-job.admin-job'); diff --git a/backend/vaa-strapi/src/api/admin-job/routes/admin-job.ts b/backend/vaa-strapi/src/api/admin-job/routes/admin-job.ts new file mode 100644 index 000000000..9b6b8f742 --- /dev/null +++ b/backend/vaa-strapi/src/api/admin-job/routes/admin-job.ts @@ -0,0 +1,23 @@ +/** + * admin-job router + */ + +import { factories } from '@strapi/strapi'; + +export default factories.createCoreRouter('api::admin-job.admin-job', { + only: ['find', 'findOne', 'create', 'update'], + config: { + find: { + policies: ['global::user-is-admin'] + }, + findOne: { + policies: ['global::user-is-admin'] + }, + create: { + policies: ['global::user-is-admin'] + }, + update: { + policies: ['global::user-is-admin'] + } + } +}); diff --git a/backend/vaa-strapi/src/api/admin-job/services/admin-job.ts b/backend/vaa-strapi/src/api/admin-job/services/admin-job.ts new file mode 100644 index 000000000..cf26ec668 --- /dev/null +++ b/backend/vaa-strapi/src/api/admin-job/services/admin-job.ts @@ -0,0 +1,7 @@ +/** + * admin-job service + */ + +import { factories } from '@strapi/strapi'; + +export default factories.createCoreService('api::admin-job.admin-job'); diff --git a/backend/vaa-strapi/src/api/question/controllers/customdata.ts b/backend/vaa-strapi/src/api/question/controllers/customdata.ts new file mode 100644 index 000000000..84c4492e2 --- /dev/null +++ b/backend/vaa-strapi/src/api/question/controllers/customdata.ts @@ -0,0 +1,29 @@ +import { updateCustomData } from '../../../util/updateCustomData'; +import type { Data } from '@strapi/strapi'; +import type { StrapiContext } from '../../../../types/customStrapiTypes'; + +export default { + /** + * Update the `Question`’s `customData` by merging the new data. + */ + async update(ctx: StrapiContext) { + const data = await updateQuestionCustomData(ctx); + ctx.response.status = 200; + ctx.response.body = { data }; + } +}; + +function updateQuestionCustomData({ + params, + request +}: StrapiContext): Promise> { + console.error({ params }); + const customData = request.body?.data; + if (!customData || typeof customData !== 'object') + throw new Error('[updateQuestionCustomData] No customData object provided.'); + return updateCustomData({ + collection: 'api::question.question', + documentId: params.id, + customData + }); +} diff --git a/backend/vaa-strapi/src/api/question/routes/00-customRoutes.ts b/backend/vaa-strapi/src/api/question/routes/00-customRoutes.ts new file mode 100644 index 000000000..657d78a1d --- /dev/null +++ b/backend/vaa-strapi/src/api/question/routes/00-customRoutes.ts @@ -0,0 +1,19 @@ +/** + * Contains the definitions for the `api::question.customData.update` action. + * NB. The filename starts with zeros so that it is matched before the core routes. + */ + +import type { Core } from '@strapi/strapi'; + +export default { + routes: [ + { + method: 'POST', + path: '/question/:id/update-custom-data', + handler: 'customdata.update', + config: { + policies: ['global::user-is-admin'] + } + } as Core.RouteConfig + ] +}; diff --git a/backend/vaa-strapi/src/components/settings/access.json b/backend/vaa-strapi/src/components/settings/access.json index 8ddb2c204..9b2883366 100644 --- a/backend/vaa-strapi/src/components/settings/access.json +++ b/backend/vaa-strapi/src/components/settings/access.json @@ -16,6 +16,9 @@ }, "answersLocked": { "type": "boolean" + }, + "adminApp": { + "type": "boolean" } } } diff --git a/backend/vaa-strapi/src/extensions/documentation/documentation/1.0.0/full_documentation.json b/backend/vaa-strapi/src/extensions/documentation/documentation/1.0.0/full_documentation.json index f5584e414..807f7ccbb 100644 --- a/backend/vaa-strapi/src/extensions/documentation/documentation/1.0.0/full_documentation.json +++ b/backend/vaa-strapi/src/extensions/documentation/documentation/1.0.0/full_documentation.json @@ -10011,6 +10011,9 @@ }, "answersLocked": { "type": "boolean" + }, + "adminApp": { + "type": "boolean" } } }, diff --git a/backend/vaa-strapi/src/extensions/users-permissions/strapi-server.ts b/backend/vaa-strapi/src/extensions/users-permissions/strapi-server.ts index 63fc5aff1..8a483c2a5 100644 --- a/backend/vaa-strapi/src/extensions/users-permissions/strapi-server.ts +++ b/backend/vaa-strapi/src/extensions/users-permissions/strapi-server.ts @@ -3,46 +3,54 @@ import { errors } from '@strapi/utils'; import fs from 'fs'; import * as candidate from './controllers/candidate'; import type { Core, UID } from '@strapi/strapi'; -import { StrapiContext } from '../../../types/customStrapiTypes'; +import { StrapiContext, StrapiRole } from '../../../types/customStrapiTypes'; import { frontendUrl, mailFrom, mailFromName, mailReplyTo } from '../../constants'; const { ValidationError } = errors; // NB! Before adding permissions here, please make sure you've implemented the appropriate access control for the resource // Make sure to allow the user access to all publicly available data +type RoleType = StrapiRole['type']; const defaultPermissions: Array<{ action: UID.Controller; - roleType: 'public' | 'authenticated'; + roleTypes: RoleType[]; }> = [ - { action: 'plugin::users-permissions.candidate.check', roleType: 'public' }, - { action: 'plugin::users-permissions.candidate.register', roleType: 'public' }, - { action: 'plugin::users-permissions.user.me', roleType: 'authenticated' }, - { action: 'plugin::upload.content-api.upload', roleType: 'authenticated' }, - { action: 'plugin::upload.content-api.destroy', roleType: 'authenticated' }, - { action: 'api::candidate.candidate.find', roleType: 'authenticated' }, - { action: 'api::candidate.answers.overwrite', roleType: 'authenticated' }, - { action: 'api::candidate.answers.update', roleType: 'authenticated' }, - { action: 'api::candidate.candidate.findOne', roleType: 'authenticated' }, - { action: 'api::candidate.candidate.update', roleType: 'authenticated' }, - { action: 'api::candidate.properties.update', roleType: 'authenticated' }, - { action: 'api::alliance.alliance.find', roleType: 'authenticated' }, - { action: 'api::alliance.alliance.findOne', roleType: 'authenticated' }, - { action: 'api::constituency.constituency.find', roleType: 'authenticated' }, - { action: 'api::constituency.constituency.findOne', roleType: 'authenticated' }, - { action: 'api::constituency-group.constituency-group.find', roleType: 'authenticated' }, - { action: 'api::constituency-group.constituency-group.findOne', roleType: 'authenticated' }, - { action: 'api::election.election.find', roleType: 'authenticated' }, - { action: 'api::election.election.findOne', roleType: 'authenticated' }, - { action: 'api::nomination.nomination.find', roleType: 'authenticated' }, - { action: 'api::nomination.nomination.findOne', roleType: 'authenticated' }, - { action: 'api::party.party.find', roleType: 'authenticated' }, - { action: 'api::party.party.findOne', roleType: 'authenticated' }, - { action: 'api::question.question.find', roleType: 'authenticated' }, - { action: 'api::question.question.findOne', roleType: 'authenticated' }, - { action: 'api::question-category.question-category.find', roleType: 'authenticated' }, - { action: 'api::question-category.question-category.findOne', roleType: 'authenticated' }, - { action: 'api::question-type.question-type.find', roleType: 'authenticated' }, - { action: 'api::question-type.question-type.findOne', roleType: 'authenticated' } + { action: 'api::admin-job.admin-job.create', roleTypes: ['admin'] }, + { action: 'api::admin-job.admin-job.find', roleTypes: ['admin'] }, + { action: 'api::admin-job.admin-job.findOne', roleTypes: ['admin'] }, + { action: 'api::admin-job.admin-job.update', roleTypes: ['admin'] }, + { action: 'api::alliance.alliance.find', roleTypes: ['authenticated'] }, + { action: 'api::alliance.alliance.findOne', roleTypes: ['authenticated'] }, + { action: 'api::candidate.answers.overwrite', roleTypes: ['authenticated'] }, + { action: 'api::candidate.answers.update', roleTypes: ['authenticated'] }, + { action: 'api::candidate.candidate.find', roleTypes: ['authenticated'] }, + { action: 'api::candidate.candidate.findOne', roleTypes: ['authenticated'] }, + { action: 'api::candidate.candidate.update', roleTypes: ['authenticated'] }, + { action: 'api::candidate.properties.update', roleTypes: ['authenticated'] }, + { action: 'api::constituency-group.constituency-group.find', roleTypes: ['authenticated'] }, + { action: 'api::constituency-group.constituency-group.findOne', roleTypes: ['authenticated'] }, + { action: 'api::constituency.constituency.find', roleTypes: ['authenticated'] }, + { action: 'api::constituency.constituency.findOne', roleTypes: ['authenticated'] }, + { action: 'api::election.election.find', roleTypes: ['authenticated'] }, + { action: 'api::election.election.findOne', roleTypes: ['authenticated'] }, + { action: 'api::nomination.nomination.find', roleTypes: ['authenticated'] }, + { action: 'api::nomination.nomination.findOne', roleTypes: ['authenticated'] }, + { action: 'api::party.party.find', roleTypes: ['authenticated'] }, + { action: 'api::party.party.findOne', roleTypes: ['authenticated'] }, + { action: 'api::question-category.question-category.find', roleTypes: ['authenticated'] }, + { action: 'api::question-category.question-category.findOne', roleTypes: ['authenticated'] }, + { action: 'api::question-type.question-type.find', roleTypes: ['authenticated'] }, + { action: 'api::question-type.question-type.findOne', roleTypes: ['authenticated'] }, + { action: 'api::question.question.find', roleTypes: ['authenticated'] }, + { action: 'api::question.question.findOne', roleTypes: ['authenticated'] }, + { action: 'api::question.customdata.update', roleTypes: ['admin'] }, + { action: 'plugin::upload.content-api.destroy', roleTypes: ['authenticated'] }, + { action: 'plugin::upload.content-api.upload', roleTypes: ['authenticated'] }, + { action: 'plugin::users-permissions.candidate.check', roleTypes: ['public'] }, + { action: 'plugin::users-permissions.candidate.register', roleTypes: ['public'] }, + { action: 'plugin::users-permissions.role.find', roleTypes: ['authenticated', 'admin'] }, + { action: 'plugin::users-permissions.role.findOne', roleTypes: ['authenticated', 'admin'] }, + { action: 'plugin::users-permissions.user.me', roleTypes: ['authenticated', 'admin'] } ]; module.exports = async (plugin: Core.Plugin) => { @@ -63,32 +71,47 @@ module.exports = async (plugin: Core.Plugin) => { advanced.email_reset_password = url; await pluginStore.set({ key: 'advanced', value: advanced }); - // Setup default permissions - for (const permission of defaultPermissions) { - const role = await strapi.query('plugin::users-permissions.role').findOne({ - where: { - type: permission.roleType - } - }); - if (!role) { - console.error(`Failed to initialize default permissions due to missing role type: ${permission.roleType}`); - continue; - } + const adminType = await strapi.query('plugin::users-permissions.role').findOne({ where: { type: 'admin' } }); - const count = await strapi.query('plugin::users-permissions.permission').count({ - where: { - action: permission.action, - role: role.id + if (!adminType) { + // Create admin role for admin-ui functions + await strapi.query('plugin::users-permissions.role').create({ + data: { + name: 'Admin', + description: 'Role for admins who can access the frontend Admin App.', + type: 'admin' } }); - if (count !== 0) continue; + } - await strapi.query('plugin::users-permissions.permission').create({ - data: { - action: permission.action, - role: role.id + // Setup default permissions + for (const permission of defaultPermissions) { + for (const roleType of permission.roleTypes) { + const role = await strapi.query('plugin::users-permissions.role').findOne({ + where: { + type: roleType + } + }); + if (!role) { + console.error(`Failed to initialize default permissions due to missing role type: ${roleType}`); + continue; } - }); + + const count = await strapi.query('plugin::users-permissions.permission').count({ + where: { + action: permission.action, + role: role.id + } + }); + if (count !== 0) continue; + + await strapi.query('plugin::users-permissions.permission').create({ + data: { + action: permission.action, + role: role.id + } + }); + } } // Setup email template (the default template also does not make the URL clickable) diff --git a/backend/vaa-strapi/src/functions/generateMockData.ts b/backend/vaa-strapi/src/functions/generateMockData.ts index 68d5f83f2..09c316d7c 100644 --- a/backend/vaa-strapi/src/functions/generateMockData.ts +++ b/backend/vaa-strapi/src/functions/generateMockData.ts @@ -9,6 +9,7 @@ import { type Faker, faker, fakerFI, fakerSV } from '@faker-js/faker'; import crypto from 'crypto'; import { loadDefaultAppSettings } from './loadDefaultAppSettings'; +import mockAdmins from './mockData/mockAdmins.json'; import mockCandidateForTesting from './mockData/mockCandidateForTesting.json'; import mockCategories from './mockData/mockCategories.json'; import mockInfoQuestions from './mockData/mockInfoQuestions.json'; @@ -218,6 +219,12 @@ export async function generateMockData() { }); console.info('Done!'); console.info('#######################################'); + console.info('inserting backend admin users'); + await createBackendAdmin().catch((e) => { + throw e; + }); + console.info('Done!'); + console.info('#######################################'); console.info('Mock data generation completed successfully!'); console.info('#######################################'); } catch (e) { @@ -250,6 +257,55 @@ async function createStrapiAdmin() { } } +async function createBackendAdmin() { + if (process.env.NODE_ENV === 'production') { + console.warn('Skipped - running in production mode.'); + return; + } + + console.info('Creating backend admin users from mock data...'); + + // Get the admin role for users-permissions plugin + const adminRole = await strapi.query('plugin::users-permissions.role').findOne({ + where: { + type: 'admin' + } + }); + + if (!adminRole) { + console.warn('Admin role not found, skipping admin user creation'); + return; + } + + // Create admin users from mockAdmins.json + for (const adminData of mockAdmins) { + const { email, username, password } = adminData; + + // Check if user already exists + const existingUser = await strapi.query('plugin::users-permissions.user').findOne({ + where: { email } + }); + + if (existingUser) { + console.info(`Admin user ${email} already exists, skipping...`); + continue; + } + + // Create the admin user + await strapi.plugin('users-permissions').service('user').add({ + username, + email, + password, + provider: 'local', + confirmed: true, + blocked: false, + role: adminRole.id + }); + + console.info(`Created admin user: ${email}`); + } +} + async function createAppCustomization() { const faqs = []; locales.forEach(({ code }) => { @@ -591,7 +647,6 @@ async function createAlliances(numParties: Array) { for (const n of numParties) { if (n > parties.length) continue; const allies = spliceRandom(parties, n); - console.error({ electionId, constituencyId, allies }); await strapi.documents('api::alliance.alliance').create({ data: { election: electionId, diff --git a/backend/vaa-strapi/src/functions/mockData/mockAdmins.json b/backend/vaa-strapi/src/functions/mockData/mockAdmins.json new file mode 100644 index 000000000..b3c2ed352 --- /dev/null +++ b/backend/vaa-strapi/src/functions/mockData/mockAdmins.json @@ -0,0 +1,7 @@ +[ + { + "username": "mock.admin", + "email": "mock.admin@openvaa.org", + "password": "Password1!" + } +] \ No newline at end of file diff --git a/backend/vaa-strapi/src/functions/setDefaultApiPermissions.ts b/backend/vaa-strapi/src/functions/setDefaultApiPermissions.ts index 153e08cd5..de95d04a8 100644 --- a/backend/vaa-strapi/src/functions/setDefaultApiPermissions.ts +++ b/backend/vaa-strapi/src/functions/setDefaultApiPermissions.ts @@ -1,9 +1,24 @@ import { PUBLIC_API } from '../util/api'; +import type { StrapiRole } from '../../types/customStrapiTypes'; -export async function setDefaultApiPermissions() { +export async function setDefaultApiPermissions(roleType: StrapiRole['type']) { console.info('[setDefaultApiPermissions] Setting default API permissions'); - const roleId = 2; // Role for public user + let roleId: number; + switch (roleType) { + case 'authenticated': { + roleId = 1; + break; + } + case 'public': { + roleId = 2; + break; + } + case 'admin': { + roleId = 3; + break; + } + } // Voter App for (const contentType of Object.values(PUBLIC_API)) { diff --git a/backend/vaa-strapi/src/index.ts b/backend/vaa-strapi/src/index.ts index fa02a8a2e..0793cb4b9 100644 --- a/backend/vaa-strapi/src/index.ts +++ b/backend/vaa-strapi/src/index.ts @@ -43,6 +43,8 @@ module.exports = { } // 2. Default API permissions - setDefaultApiPermissions(); + setDefaultApiPermissions('public'); + setDefaultApiPermissions('authenticated'); + setDefaultApiPermissions('admin'); } }; diff --git a/backend/vaa-strapi/src/policies/debug.ts b/backend/vaa-strapi/src/policies/debug.ts index 2760d0327..ad08d187d 100644 --- a/backend/vaa-strapi/src/policies/debug.ts +++ b/backend/vaa-strapi/src/policies/debug.ts @@ -3,7 +3,7 @@ import type { StrapiContext } from '../../types/customStrapiTypes'; /** * A policy for debugging that always returns `true` and logs the context. */ -export default function debug({ params, request, state, ...rest }: StrapiContext): true { - console.info('Policy: global::debug called with ctx:', { params, request, state, ...rest }); +export default function debug({ params, request, state, response }: StrapiContext): true { + console.info('Policy: global::debug called with ctx:', { params, request, state, response }); return true; } diff --git a/backend/vaa-strapi/src/policies/user-is-admin.ts b/backend/vaa-strapi/src/policies/user-is-admin.ts new file mode 100644 index 000000000..9e9e31d7c --- /dev/null +++ b/backend/vaa-strapi/src/policies/user-is-admin.ts @@ -0,0 +1,16 @@ +import { warn } from '../util/logger'; +import type { StrapiContext } from '../../types/customStrapiTypes'; + +/** + * Policy that only allows admin users to use LLM functions. + * NB! Admin role is different from Strapi admin role + */ +export default function isAdmin(ctx: StrapiContext): boolean { + const userIsAuthenticated = ctx.state?.isAuthenticated; + const role = ctx.state?.user?.role?.type; + if (role === 'admin' && userIsAuthenticated) { + return true; + } + warn('[global::user-is-admin] triggered by:', ctx.request); + return false; +} diff --git a/backend/vaa-strapi/src/util/updateCustomData.ts b/backend/vaa-strapi/src/util/updateCustomData.ts new file mode 100644 index 000000000..5bc22faae --- /dev/null +++ b/backend/vaa-strapi/src/util/updateCustomData.ts @@ -0,0 +1,36 @@ +import { warn } from './logger'; +import type { Data } from '@strapi/strapi'; + +/** + * Update any object’s `customData` by merging it with the existing value. + * @param entityType - The type of the entity + * @returns The updated entity + */ +export async function updateCustomData< + TCollection extends 'api::question.question' | 'api::question-category.question-category' +>({ + collection, + documentId, + customData +}: { + collection: TCollection; + documentId: string; + customData: object; +}): Promise> { + if (!customData || typeof customData !== 'object') throw new Error('No customData object provided.'); + + const { customData: currentCustomData } = await strapi.documents(collection).findOne({ documentId }); + + const updatedData = { + ...(typeof currentCustomData === 'object' ? currentCustomData : {}), + ...customData + }; + + const update = strapi.documents(collection).update; + type Args = Parameters[0]; + + return update({ documentId, data: { customData: updatedData } } as unknown as Args).catch((e) => { + warn(`[updateCustomData] Could not update document ${collection}/${documentId}`, e); + throw e; + }); +} diff --git a/backend/vaa-strapi/types/customStrapiTypes.d.ts b/backend/vaa-strapi/types/customStrapiTypes.d.ts index 29845ec12..036c5eda5 100644 --- a/backend/vaa-strapi/types/customStrapiTypes.d.ts +++ b/backend/vaa-strapi/types/customStrapiTypes.d.ts @@ -9,7 +9,7 @@ export type StrapiContext = RequestContext & { query: StrapiQuery | Record>; url: string; }; - state?: { user: { id: number } }; + state?: { user: { id: number; role: StrapiRole } }; }; /** @@ -26,4 +26,15 @@ export type StrapiQuery = { locale: string | Array; }; +/** + * A non-exhaustive type for a Strapi Role. + * See: https://docs.strapi.io/dev-docs/backend-customization/requests-responses#ctxstateuser + */ +export type StrapiRole = { + id: number; + documentId: string; + description: string; + type: 'authenticated' | 'public' | 'admin'; +}; + type RequestContext = ReturnType; diff --git a/backend/vaa-strapi/types/generated/components.d.ts b/backend/vaa-strapi/types/generated/components.d.ts index 6722858a6..8ec492306 100644 --- a/backend/vaa-strapi/types/generated/components.d.ts +++ b/backend/vaa-strapi/types/generated/components.d.ts @@ -43,6 +43,7 @@ export interface SettingsAccess extends Struct.ComponentSchema { displayName: 'Access'; }; attributes: { + adminApp: Schema.Attribute.Boolean; answersLocked: Schema.Attribute.Boolean; candidateApp: Schema.Attribute.Boolean; underMaintenance: Schema.Attribute.Boolean; @@ -71,6 +72,7 @@ export interface SettingsEntities extends Struct.ComponentSchema { }; attributes: { hideIfMissingAnswers: Schema.Attribute.Component<'settings.hide-if-missing-answers', false>; + showAllNominations: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo; }; } @@ -233,6 +235,7 @@ export interface SettingsQuestions extends Struct.ComponentSchema { }; attributes: { categoryIntros: Schema.Attribute.Component<'settings.questions-category-intros', false>; + interactiveInfo: Schema.Attribute.Component<'settings.questions-interactive-info', false>; questionsIntro: Schema.Attribute.Component<'settings.questions-intro', false> & Schema.Attribute.Required; showCategoryTags: Schema.Attribute.Boolean & Schema.Attribute.Required; showResultsLink: Schema.Attribute.Boolean; @@ -251,6 +254,17 @@ export interface SettingsQuestionsCategoryIntros extends Struct.ComponentSchema }; } +export interface SettingsQuestionsInteractiveInfo extends Struct.ComponentSchema { + collectionName: 'components_settings_questions_interactive_info'; + info: { + description: ''; + displayName: 'Questions - Interactive Info'; + }; + attributes: { + enabled: Schema.Attribute.Boolean; + }; +} + export interface SettingsQuestionsIntro extends Struct.ComponentSchema { collectionName: 'components_settings_questions_intros'; info: { @@ -348,6 +362,7 @@ declare module '@strapi/strapi' { 'settings.notifications-notification-data': SettingsNotificationsNotificationData; 'settings.questions': SettingsQuestions; 'settings.questions-category-intros': SettingsQuestionsCategoryIntros; + 'settings.questions-interactive-info': SettingsQuestionsInteractiveInfo; 'settings.questions-intro': SettingsQuestionsIntro; 'settings.results': SettingsResults; 'settings.results-candidate-card-contents': SettingsResultsCandidateCardContents; diff --git a/backend/vaa-strapi/types/generated/contentTypes.d.ts b/backend/vaa-strapi/types/generated/contentTypes.d.ts index bc668498a..cf6b3a26b 100644 --- a/backend/vaa-strapi/types/generated/contentTypes.d.ts +++ b/backend/vaa-strapi/types/generated/contentTypes.d.ts @@ -328,6 +328,42 @@ export interface AdminUser extends Struct.CollectionTypeSchema { }; } +export interface ApiAdminJobAdminJob extends Struct.CollectionTypeSchema { + collectionName: 'admin_jobs'; + info: { + description: ''; + displayName: 'Admin Job'; + pluralName: 'admin-jobs'; + singularName: 'admin-job'; + }; + options: { + draftAndPublish: false; + }; + attributes: { + author: Schema.Attribute.Email & Schema.Attribute.Required; + createdAt: Schema.Attribute.DateTime; + createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & Schema.Attribute.Private; + endStatus: Schema.Attribute.Enumeration<['completed', 'failed']>; + endTime: Schema.Attribute.DateTime; + input: Schema.Attribute.JSON; + jobId: Schema.Attribute.UID & Schema.Attribute.Required; + jobType: Schema.Attribute.String & + Schema.Attribute.Required & + Schema.Attribute.SetMinMaxLength<{ + minLength: 1; + }>; + locale: Schema.Attribute.String & Schema.Attribute.Private; + localizations: Schema.Attribute.Relation<'oneToMany', 'api::admin-job.admin-job'> & Schema.Attribute.Private; + messages: Schema.Attribute.JSON; + metadata: Schema.Attribute.JSON; + output: Schema.Attribute.JSON; + publishedAt: Schema.Attribute.DateTime; + startTime: Schema.Attribute.DateTime; + updatedAt: Schema.Attribute.DateTime; + updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & Schema.Attribute.Private; + }; +} + export interface ApiAllianceAlliance extends Struct.CollectionTypeSchema { collectionName: 'alliances'; info: { @@ -352,7 +388,7 @@ export interface ApiAllianceAlliance extends Struct.CollectionTypeSchema { locale: Schema.Attribute.String & Schema.Attribute.Private; localizations: Schema.Attribute.Relation<'oneToMany', 'api::alliance.alliance'> & Schema.Attribute.Private; name: Schema.Attribute.JSON; - parties: Schema.Attribute.Relation<'oneToMany', 'api::party.party'>; + parties: Schema.Attribute.Relation<'manyToMany', 'api::party.party'>; publishedAt: Schema.Attribute.DateTime; shortName: Schema.Attribute.JSON; updatedAt: Schema.Attribute.DateTime; @@ -454,6 +490,7 @@ export interface ApiCandidateCandidate extends Struct.CollectionTypeSchema { party: Schema.Attribute.Relation<'manyToOne', 'api::party.party'>; publishedAt: Schema.Attribute.DateTime; registrationKey: Schema.Attribute.String & Schema.Attribute.Private; + termsOfUseAccepted: Schema.Attribute.DateTime; updatedAt: Schema.Attribute.DateTime; updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & Schema.Attribute.Private; user: Schema.Attribute.Relation<'oneToOne', 'plugin::users-permissions.user'> & Schema.Attribute.Private; @@ -634,7 +671,7 @@ export interface ApiNominationNomination extends Struct.CollectionTypeSchema { localizations: Schema.Attribute.Relation<'oneToMany', 'api::nomination.nomination'> & Schema.Attribute.Private; party: Schema.Attribute.Relation<'manyToOne', 'api::party.party'>; publishedAt: Schema.Attribute.DateTime; - unconfirmed: Schema.Attribute.Boolean; + unconfirmed: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo; updatedAt: Schema.Attribute.DateTime; updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & Schema.Attribute.Private; }; @@ -652,7 +689,7 @@ export interface ApiPartyParty extends Struct.CollectionTypeSchema { draftAndPublish: false; }; attributes: { - alliance: Schema.Attribute.Relation<'manyToOne', 'api::alliance.alliance'>; + alliances: Schema.Attribute.Relation<'manyToMany', 'api::alliance.alliance'>; answers: Schema.Attribute.JSON; candidates: Schema.Attribute.Relation<'oneToMany', 'api::candidate.candidate'>; color: Schema.Attribute.String; @@ -1182,6 +1219,7 @@ declare module '@strapi/strapi' { 'admin::transfer-token': AdminTransferToken; 'admin::transfer-token-permission': AdminTransferTokenPermission; 'admin::user': AdminUser; + 'api::admin-job.admin-job': ApiAdminJobAdminJob; 'api::alliance.alliance': ApiAllianceAlliance; 'api::app-customization.app-customization': ApiAppCustomizationAppCustomization; 'api::app-setting.app-setting': ApiAppSettingAppSetting; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 1e59563f6..e1b761bac 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -26,6 +26,7 @@ services: CACHE_TTL: ${CACHE_TTL} CACHE_LRU_SIZE: ${CACHE_LRU_SIZE} CACHE_EXPIRATION_INTERVAL: ${CACHE_EXPIRATION_INTERVAL} + LLM_OPENAI_API_KEY: ${LLM_OPENAI_API_KEY} awslocal: extends: file: ./backend/vaa-strapi/docker-compose.dev.yml @@ -65,6 +66,7 @@ services: DATABASE_SSL_SELF: ${DATABASE_SSL_SELF} GENERATE_MOCK_DATA_ON_INITIALISE: ${GENERATE_MOCK_DATA_ON_INITIALISE} GENERATE_MOCK_DATA_ON_RESTART: ${GENERATE_MOCK_DATA_ON_RESTART} + LLM_OPENAI_API_KEY: ${LLM_OPENAI_API_KEY} LOAD_DATA_ON_INITIALISE_FOLDER: ${LOAD_DATA_ON_INITIALISE_FOLDER} AWS_SES_ACCESS_KEY_ID: ${AWS_SES_ACCESS_KEY_ID} AWS_SES_SECRET_ACCESS_KEY: ${AWS_SES_SECRET_ACCESS_KEY} diff --git a/docs/README.md b/docs/README.md index 708fc4191..0b6d6142e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ Instructions for developers and contributors. - [LLM features](llm-features.md) - Experimental LLM features - [Troubleshooting](troubleshooting.md) - Common issues and solutions - [Code Review Checklist](code-review-checklist.md) - Checklist for code reviews +- [Auto Documentation](auto-documentation.md) - Automatic documentation generation system ## Full Table of Contents diff --git a/docs/auto-documentation.md b/docs/auto-documentation.md new file mode 100644 index 000000000..264828c60 --- /dev/null +++ b/docs/auto-documentation.md @@ -0,0 +1,454 @@ +# Automatic Documentation Generation + +This document describes the automatic documentation generation system for OpenVAA. + +## Overview + +The documentation system automatically generates comprehensive documentation from: + +1. **TypeScript code** - Using TypeDoc to extract TSDoc comments +2. **Svelte components** - Extracting `@component` docstrings +3. **Route structure** - Mapping the SvelteKit routes directory +4. **README files** - Integrating README content from directories + +## Generated Documentation Structure + +``` +docs/generated/ # Auto-generated (gitignored) +├── README.md # Main index +├── api/ # TypeDoc-generated API docs +│ ├── packages/ # Package documentation +│ │ ├── core/ +│ │ ├── data/ +│ │ ├── matching/ +│ │ ├── filters/ +│ │ ├── app-shared/ +│ │ ├── llm/ +│ │ ├── argument-condensation/ +│ │ └── question-info/ +│ └── frontend/ # Frontend library documentation +│ ├── contexts/ +│ ├── utils/ +│ └── api/ +├── components/ # Svelte component docs +│ ├── README.md # Component table of contents +│ ├── components/ # From frontend/src/lib/components +│ ├── candidate/ # From frontend/src/lib/candidate/components +│ └── dynamic-components/ # From frontend/src/lib/dynamic-components +└── routes/ # Route map + └── README.md # Route structure +``` + +## Quick Start + +### Generate All Documentation + +```bash +# Install dependencies (if not already done) +yarn install + +# Build shared packages (required for TypeDoc) +yarn build:shared + +# Generate all documentation +yarn docs:generate +``` + +The generated documentation will be in `docs/generated/`. + +### Generate Specific Sections + +```bash +# TypeDoc API documentation only +yarn docs:typedoc + +# Svelte component documentation only +yarn docs:components + +# Route map only +yarn docs:routes +``` + +## How It Works + +### 1. TypeDoc for API Documentation + +**Configuration:** `typedoc.json` + +TypeDoc generates markdown documentation from TSDoc comments in TypeScript files. It covers: + +- All packages in `packages/*` +- Frontend contexts (`frontend/src/lib/contexts`) +- Frontend utilities (`frontend/src/lib/utils`) +- Frontend API adapters (`frontend/src/lib/api`) + +**Features:** +- Generates Mermaid class diagrams +- Links to GitHub source code +- Supports hierarchical navigation +- Excludes test files and private APIs + +**Script:** `typedoc` command configured in `typedoc.json` + +### 2. Svelte Component Documentation + +**Script:** `scripts/extract-component-docs.ts` + +This custom script: +1. Finds all `.svelte` files in component directories +2. Extracts the `@component` docstring (markdown format) +3. Links to corresponding `.type.ts` TypeDoc documentation +4. Includes README.md files from component directories +5. Generates a table of contents organized by directory + +**Covered directories:** +- `frontend/src/lib/components/` +- `frontend/src/lib/candidate/components/` +- `frontend/src/lib/dynamic-components/` + +**Output:** One markdown file per component in `docs/generated/components/` + +### 3. Route Map Generation + +**Script:** `scripts/generate-route-map.ts` + +This script: +1. Traverses the `frontend/src/routes/` directory tree +2. Identifies route types: + - Standard routes (`/about`) + - Dynamic routes (`[id]`, `[slug=matcher]`) + - Layout groups (`(voters)`, `(protected)`) + - Server endpoints (`+server.ts`) + - Layouts (`+layout.svelte`) +3. Includes README.md content from route directories +4. Generates a hierarchical tree structure + +**Output:** `docs/generated/routes/README.md` + +### 4. Main Orchestrator + +**Script:** `scripts/generate-docs.ts` + +This main script: +1. Runs TypeDoc for packages +2. Runs TypeDoc for frontend libraries +3. Extracts Svelte component documentation +4. Generates route map +5. Creates a comprehensive index with links to all sections + +## Writing Documentation + +### For TypeScript Code + +Use TSDoc comments above exports: + +```typescript +/** + * Calculates the matching score between a voter and a candidate. + * + * The score is normalized to a 0-1 range where 1 is a perfect match. + * + * @param voter - The voter's answers to questions + * @param candidate - The candidate's positions on questions + * @param options - Optional matching algorithm parameters + * @returns A match score between 0 and 1 + * + * @example + * ```typescript + * const score = calculateMatch(voterAnswers, candidateAnswers, { + * metric: 'manhattan' + * }); + * console.log(`Match: ${score * 100}%`); + * ``` + * + * @see {@link MatchOptions} for available options + * @see {@link VoterAnswers} for the voter answer format + */ +export function calculateMatch( + voter: VoterAnswers, + candidate: CandidateAnswers, + options?: MatchOptions +): number { + // Implementation +} +``` + +**TSDoc tags:** +- `@param` - Parameter description +- `@returns` - Return value description +- `@example` - Usage example +- `@see` - Cross-reference to related items +- `@throws` - Exceptions that may be thrown +- `@deprecated` - Mark as deprecated +- `@internal` - Exclude from documentation + +### For Svelte Components + +Add a `@component` docstring at the top of the `.svelte` file: + +```svelte + + + + + +``` + +**Component docstring sections:** +- Brief description (first paragraph) +- Detailed description +- `### Properties` - Component props +- `### Slots` - Available slots +- `### Events` - Events and callbacks +- `### Bindable Functions` - Functions that can be bound +- `### Accessibility` - Accessibility considerations +- `### Usage` - Code examples + +### For Routes + +Add `README.md` files in route directories: + +```markdown +# Admin Routes + +Protected routes for application administrators. + +## Authentication + +All routes require admin authentication. Unauthenticated users are +redirected to `/admin/login`. + +## Available Routes + +- `/admin` - Dashboard with system overview +- `/admin/jobs` - View and manage background jobs +- `/admin/question-info` - Generate question information with AI +- `/admin/argument-condensation` - Condense candidate arguments + +## Permissions + +Admin users must have the `admin` role assigned in Strapi. +``` + +## TypeDoc Configuration + +The `typedoc.json` configuration includes: + +- **Entry points:** All packages + frontend library folders +- **Plugins:** + - `typedoc-plugin-markdown` - Markdown output + - `typedoc-plugin-mermaid` - Class diagram generation +- **Exclusions:** Test files, node_modules, type declaration files +- **Source links:** Links to GitHub source code +- **Navigation:** Categorized by groups + +## Troubleshooting + +### "Module not found" errors + +TypeDoc requires built packages. Run: + +```bash +yarn build:shared +yarn docs:generate +``` + +### Missing component documentation + +Ensure your component has a `@component` docstring: + +```svelte + +``` + +### TypeDoc warnings + +TypeDoc may warn about missing exports or private types. This is usually fine - only exported public APIs are documented. + +### Script execution errors + +Ensure you have the required dependencies: + +```bash +yarn install +``` + +Scripts require: +- `tsx` - TypeScript execution +- `glob` - File pattern matching +- `typedoc` and plugins - API documentation + +## CI/CD Integration + +### GitHub Actions Example + +```yaml +name: Generate Documentation + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + docs: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: yarn install + + - name: Build packages + run: yarn build:shared + + - name: Generate documentation + run: yarn docs:generate + + - name: Upload documentation + uses: actions/upload-artifact@v3 + with: + name: documentation + path: docs/generated/ + + # Optional: Deploy to GitHub Pages + - name: Deploy to GitHub Pages + if: github.ref == 'refs/heads/main' + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs/generated +``` + +## Future Enhancements + +Planned improvements: + +1. **Storybook Integration** + - Interactive component playground + - Visual regression testing + - Component variant exploration + +2. **Search Functionality** + - Full-text search across all documentation + - Symbol search for APIs + - Component search + +3. **Version Support** + - Generate docs for different releases + - Version selector in navigation + - Changelog integration + +4. **Enhanced Diagrams** + - Architecture diagrams + - Data flow diagrams + - Component dependency graphs + +5. **Documentation Site** + - SvelteKit-based documentation website + - Interactive examples + - Live code playgrounds + +6. **Cross-References** + - Automatic linking between related items + - "Used by" and "Uses" sections + - Import path suggestions + +## Contributing + +When adding new code: + +1. **Add TSDoc comments** to all exported functions, classes, and types +2. **Add `@component` docstrings** to all Svelte components +3. **Include usage examples** in documentation +4. **Add README.md files** to new route sections +5. **Test documentation generation** before committing: + ```bash + yarn docs:generate + ``` + +## Support + +For issues with the documentation system: +- Check the [Troubleshooting](#troubleshooting) section +- Review the [Contributing guide](contributing.md) +- Open an issue on GitHub + +--- + +*Last updated: 2025-12-14* diff --git a/docs/code-review-checklist.md b/docs/code-review-checklist.md index 2f42e3963..08d9cc59d 100644 --- a/docs/code-review-checklist.md +++ b/docs/code-review-checklist.md @@ -10,7 +10,7 @@ When performing code review, double check all of the items below: - [ ] All new components, functions and other entities are documented - [ ] The repo documentation markdown files are updated if the changes touch upon those. - [ ] If the change adds functions available to the user, tracking events are enabled with new ones defined if needed. -- [ ] Any new Svelte components that have been created, follow the [Svelte component guidelines](contributing.md#svelte-components). +- [ ] Any new Svelte components that have been created follow the [Svelte component guidelines](contributing.md#svelte-components). - [ ] Errors are handled properly and logged in the code. - [ ] Troubleshoot any failing checks in the PR. - [ ] Check that parts of the application that share dependencies with the PR but are not included in it are not unduly affected. diff --git a/docs/contributing.md b/docs/contributing.md index 8b11f6531..03ac6d607 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -379,7 +379,14 @@ let className: $$Props['class'] = $$props['class']; Follow Svelte's [guidelines for component documentation](https://svelte.dev/docs/faq#how-do-i-document-my-components). For an example, see [`IconBase`](/frontend/src/lib/components/icon/base/IconBase.svelte) component and its associated [type definition](/frontend/src/lib/components/icon/base/IconBase.type.ts). -Place the Svelte docstring at the top of the file, before the ` + +
+
+ +
+

+ {$t(`adminApp.jobs.features.${feature}.title`)} +

+ {#if showFeatureLink} +
+
+ {/if} +
+ + +
+

+ {$t('adminApp.jobs.activeJobs')} +

+ {#if !activeJob} +
+
+
{$t('adminApp.jobs.noActiveJobs')}
+
+
+ {/if} + + {#if activeJob && activeJob.status === 'running'} + + {:else if activeJob && activeJob.status === 'aborting'} + + {/if} +
+ + +
+
+

+ {$t('adminApp.jobs.pastJobs')} + {#if pastJobs.length > 0} + {pastJobs.length} + {/if} +

+ {#if pastJobs.length > 0} +
+
+ {/if} +
+ + {#if pastJobs.length === 0} +
+
+
{$t('adminApp.jobs.noPastJobs')}
+
+
+ {:else if pastOpen} +
+
+ {#each pastJobs as job (job.id)} +
+ +
+ {/each} +
+
+ {/if} +
+
+
diff --git a/frontend/src/lib/admin/components/jobs/FeatureJobs.type.ts b/frontend/src/lib/admin/components/jobs/FeatureJobs.type.ts new file mode 100644 index 000000000..355332b7e --- /dev/null +++ b/frontend/src/lib/admin/components/jobs/FeatureJobs.type.ts @@ -0,0 +1,8 @@ +import type { SvelteHTMLElements } from 'svelte/elements'; +import type { AdminFeature } from '$lib/admin/features'; + +export type FeatureJobsProps = SvelteHTMLElements['section'] & { + feature: AdminFeature; + /** Whether to show the "Go to Feature" link. @default true */ + showFeatureLink?: boolean; +}; diff --git a/frontend/src/lib/admin/components/jobs/JobDetails.svelte b/frontend/src/lib/admin/components/jobs/JobDetails.svelte new file mode 100644 index 000000000..8fd489828 --- /dev/null +++ b/frontend/src/lib/admin/components/jobs/JobDetails.svelte @@ -0,0 +1,172 @@ + + + + + +
+
+ +
+
+ + {job.status} + +
+ {$t('adminApp.jobs.id')}: {job.id.slice(0, 8)}... +
+
+ + {#if job.status === 'running'} + + + {/if} +
+ + +
+
+ {$t('adminApp.jobs.author')} + {job.author} +
+
+ {$t('adminApp.jobs.started')} + {new Date(job.startTime).toLocaleString()} +
+ {#if job.endTime} +
+ {$t('adminApp.jobs.duration')} + {formatJobDuration(job)} +
+ {/if} +
+ + + {#if job.status === 'running'} + + {:else if job.status === 'aborting'} +
+
+
+ + {$t('adminApp.jobs.aborting')} +
+ {Math.round(job.progress * 100)}% +
+ +
+ {$t('adminApp.jobs.abortingInfo')} +
+
+ {/if} + + +
+
+ + + {#if messagesOpen} +
+ + +
+ {/if} +
+
diff --git a/frontend/src/lib/admin/components/jobs/JobDetails.type.ts b/frontend/src/lib/admin/components/jobs/JobDetails.type.ts new file mode 100644 index 000000000..ea5277bd4 --- /dev/null +++ b/frontend/src/lib/admin/components/jobs/JobDetails.type.ts @@ -0,0 +1,6 @@ +import type { SvelteHTMLElements } from 'svelte/elements'; +import type { JobInfo } from '$lib/server/admin/jobs/jobStore.type'; + +export type JobDetailsProps = SvelteHTMLElements['article'] & { + job: JobInfo; +}; diff --git a/frontend/src/lib/admin/components/jobs/WithPolling.svelte b/frontend/src/lib/admin/components/jobs/WithPolling.svelte new file mode 100644 index 000000000..18fc4ab1b --- /dev/null +++ b/frontend/src/lib/admin/components/jobs/WithPolling.svelte @@ -0,0 +1,28 @@ + + + + + diff --git a/frontend/src/lib/admin/components/jobs/index.ts b/frontend/src/lib/admin/components/jobs/index.ts new file mode 100644 index 000000000..069cac1f5 --- /dev/null +++ b/frontend/src/lib/admin/components/jobs/index.ts @@ -0,0 +1,5 @@ +export { default as FeatureJobs } from './FeatureJobs.svelte'; +export * from './FeatureJobs.type'; +export { default as JobDetails } from './JobDetails.svelte'; +export * from './JobDetails.type'; +export { default as WithPolling } from './WithPolling.svelte'; diff --git a/frontend/src/lib/admin/components/jobs/shared.ts b/frontend/src/lib/admin/components/jobs/shared.ts new file mode 100644 index 000000000..cbf97ba7d --- /dev/null +++ b/frontend/src/lib/admin/components/jobs/shared.ts @@ -0,0 +1,9 @@ +/** + * Shared constants for job message components (InfoMessages, WarningMessages, JobDetails, etc.) + */ + +/** Default maximum number of messages to retain/show per list */ +export const DEFAULT_MAX_MESSAGES = 1000; + +/** Default height class for message scroll areas */ +export const DEFAULT_MESSAGES_HEIGHT = 'max-h-64'; diff --git a/frontend/src/lib/admin/components/languageFeatures/LanguageSelector.svelte b/frontend/src/lib/admin/components/languageFeatures/LanguageSelector.svelte new file mode 100644 index 000000000..1b7ae1e44 --- /dev/null +++ b/frontend/src/lib/admin/components/languageFeatures/LanguageSelector.svelte @@ -0,0 +1,50 @@ + + + + +
+ + +{/if} + +{#if canonicalOptions.length === 1}
{selectedPrefix} - {options[0].label} + {canonicalOptions[0].label}
{:else if autocomplete === 'on'}
@@ -309,7 +319,7 @@ The component follows the [WGAI Combobox pattern](https://www.w3.org/WAI/ARIA/ap - {#each options as { id, label }} + {#each canonicalOptions as { id, label }}