diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 6b6dbc9..e6762df 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -15,7 +15,8 @@ RUN apt-get install -y \ vim \ htop \ jq \ - locales + locales \ + ripgrep RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && \ diff --git a/apps/chronos/src/routes/doorlock/cards.ts b/apps/chronos/src/routes/doorlock/cards.ts index ea5fb82..c472be5 100644 --- a/apps/chronos/src/routes/doorlock/cards.ts +++ b/apps/chronos/src/routes/doorlock/cards.ts @@ -243,6 +243,17 @@ export const createCardRoute = doorlockFactory.createHandlers( export const updateCardRoute = doorlockFactory.createHandlers( describeRoute({ description: 'Update an access card', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The access card ID.', + type: 'string', + }, + }, + ], requestBody: { content: { 'application/json': { @@ -320,6 +331,17 @@ export const updateCardRoute = doorlockFactory.createHandlers( export const deleteCardRoute = doorlockFactory.createHandlers( describeRoute({ description: 'Delete an access card', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The access card ID.', + type: 'string', + }, + }, + ], responses: { 200: { description: 'Card deleted' }, 404: { description: 'Card not found' }, diff --git a/apps/chronos/src/routes/doorlock/devices.ts b/apps/chronos/src/routes/doorlock/devices.ts index f1889d9..3052c94 100644 --- a/apps/chronos/src/routes/doorlock/devices.ts +++ b/apps/chronos/src/routes/doorlock/devices.ts @@ -152,6 +152,17 @@ export const createDeviceRoute = doorlockFactory.createHandlers( export const updateDeviceRoute = doorlockFactory.createHandlers( describeRoute({ description: 'Update an existing doorlock device', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The doorlock device ID.', + type: 'string', + }, + }, + ], requestBody: { content: { 'application/json': { @@ -216,6 +227,17 @@ export const updateDeviceRoute = doorlockFactory.createHandlers( export const deleteDeviceRoute = doorlockFactory.createHandlers( describeRoute({ description: 'Delete a doorlock device', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The doorlock device ID.', + type: 'string', + }, + }, + ], responses: { 200: { description: 'Device deleted' }, }, diff --git a/apps/chronos/src/routes/doorlock/ota.ts b/apps/chronos/src/routes/doorlock/ota.ts index 6c70835..1d6464e 100644 --- a/apps/chronos/src/routes/doorlock/ota.ts +++ b/apps/chronos/src/routes/doorlock/ota.ts @@ -21,6 +21,17 @@ const otaPayloadSchema = z.object({ export const triggerDeviceOtaRoute = doorlockFactory.createHandlers( describeRoute({ description: 'Trigger an OTA update on a specific device', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The doorlock device ID.', + type: 'string', + }, + }, + ], requestBody: { content: { 'application/json': { diff --git a/apps/chronos/src/routes/doorlock/self.ts b/apps/chronos/src/routes/doorlock/self.ts index 124f35a..497f51a 100644 --- a/apps/chronos/src/routes/doorlock/self.ts +++ b/apps/chronos/src/routes/doorlock/self.ts @@ -94,6 +94,17 @@ export const listSelfCardsRoute = doorlockFactory.createHandlers( export const updateSelfCardFrozenRoute = doorlockFactory.createHandlers( describeRoute({ description: 'Update the frozen state of a user-owned card', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The user-owned card ID.', + type: 'string', + }, + }, + ], requestBody: { content: { 'application/json': { @@ -162,6 +173,17 @@ export const activateVirtualCardRoute = doorlockFactory.createHandlers( describeRoute({ description: 'Activate an authorized device using a user-owned virtual card', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The user-owned card ID.', + type: 'string', + }, + }, + ], requestBody: { content: { 'application/json': { diff --git a/apps/chronos/src/routes/doorlock/stats.ts b/apps/chronos/src/routes/doorlock/stats.ts index 6dd1339..c920750 100644 --- a/apps/chronos/src/routes/doorlock/stats.ts +++ b/apps/chronos/src/routes/doorlock/stats.ts @@ -190,6 +190,17 @@ export const doorlockStatsRoute = doorlockFactory.createHandlers( export const deviceStatsRoute = doorlockFactory.createHandlers( describeRoute({ description: 'Get device health statistics', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The doorlock device ID.', + type: 'string', + }, + }, + ], responses: { 200: { content: { diff --git a/apps/chronos/src/routes/doorlock/websocket-handler.ts b/apps/chronos/src/routes/doorlock/websocket-handler.ts index d6a2e8a..eb88032 100644 --- a/apps/chronos/src/routes/doorlock/websocket-handler.ts +++ b/apps/chronos/src/routes/doorlock/websocket-handler.ts @@ -3,6 +3,7 @@ import type { ServerWebSocket } from 'bun'; import { and, eq } from 'drizzle-orm'; import { upgradeWebSocket } from 'hono/bun'; import { HTTPException } from 'hono/http-exception'; +import { describeRoute } from 'hono-openapi'; import { z } from 'zod'; import { db } from '#database'; import { @@ -181,6 +182,30 @@ export const syncDatabase = async (deviceId: string) => { }; export const websocketHandler = doorlockFactory.createHandlers( + describeRoute({ + description: + 'Upgrade an authenticated device connection to WebSocket for doorlock events.', + parameters: [ + { + in: 'header', + name: 'X-Aegis-Device-Token', + required: true, + schema: { + description: 'Device API token used for WebSocket authentication.', + type: 'string', + }, + }, + ], + responses: { + 101: { + description: 'Switching Protocols', + }, + 401: { + description: 'Invalid or missing device token', + }, + }, + tags: ['Doorlock'], + }), async (c, next) => { const gotToken = c.req.header('X-Aegis-Device-Token'); if (!gotToken) { diff --git a/apps/chronos/src/routes/news/announcements.ts b/apps/chronos/src/routes/news/announcements.ts index 5bd6b1c..54f6803 100644 --- a/apps/chronos/src/routes/news/announcements.ts +++ b/apps/chronos/src/routes/news/announcements.ts @@ -76,6 +76,26 @@ export const listAnnouncements = newsFactory.createHandlers( describeRoute({ description: 'List active announcements within date range, filtered by user cohort', + parameters: [ + { + in: 'query', + name: 'limit', + required: false, + schema: { default: 20, minimum: 1, type: 'number' }, + }, + { + in: 'query', + name: 'offset', + required: false, + schema: { default: 0, minimum: 0, type: 'number' }, + }, + { + in: 'query', + name: 'includeExpired', + required: false, + schema: { default: false, type: 'boolean' }, + }, + ], responses: { 200: { content: { @@ -166,6 +186,14 @@ export const listAnnouncements = newsFactory.createHandlers( export const getAnnouncement = newsFactory.createHandlers( describeRoute({ description: 'Get a single announcement by ID', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { format: 'uuid', type: 'string' }, + }, + ], responses: { 200: { content: { @@ -293,6 +321,14 @@ export const createAnnouncement = newsFactory.createHandlers( export const updateAnnouncement = newsFactory.createHandlers( describeRoute({ description: 'Update an existing announcement', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { format: 'uuid', type: 'string' }, + }, + ], requestBody: { content: { 'application/json': { @@ -406,6 +442,14 @@ export const updateAnnouncement = newsFactory.createHandlers( export const deleteAnnouncement = newsFactory.createHandlers( describeRoute({ description: 'Delete an announcement', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { format: 'uuid', type: 'string' }, + }, + ], responses: { 200: { content: { diff --git a/apps/chronos/src/routes/news/blogs.ts b/apps/chronos/src/routes/news/blogs.ts index 6c0df15..dae07cd 100644 --- a/apps/chronos/src/routes/news/blogs.ts +++ b/apps/chronos/src/routes/news/blogs.ts @@ -71,6 +71,20 @@ const checkSlugExists = async (slug: string, excludeId?: string) => { export const listPublishedBlogs = newsFactory.createHandlers( describeRoute({ description: 'List published blog posts (public, no auth required)', + parameters: [ + { + in: 'query', + name: 'limit', + required: false, + schema: { default: 20, minimum: 1, type: 'number' }, + }, + { + in: 'query', + name: 'offset', + required: false, + schema: { default: 0, minimum: 0, type: 'number' }, + }, + ], responses: { 200: { content: { @@ -123,6 +137,14 @@ export const listPublishedBlogs = newsFactory.createHandlers( export const getBlogBySlug = newsFactory.createHandlers( describeRoute({ description: 'Get a published blog post by slug (public, no auth required)', + parameters: [ + { + in: 'path', + name: 'slug', + required: true, + schema: { type: 'string' }, + }, + ], responses: { 200: { content: { @@ -173,6 +195,20 @@ export const getBlogBySlug = newsFactory.createHandlers( export const listDrafts = newsFactory.createHandlers( describeRoute({ description: 'List all blog posts including drafts (requires permission)', + parameters: [ + { + in: 'query', + name: 'limit', + required: false, + schema: { default: 20, minimum: 1, type: 'number' }, + }, + { + in: 'query', + name: 'offset', + required: false, + schema: { default: 0, minimum: 0, type: 'number' }, + }, + ], responses: { 200: { content: { @@ -225,6 +261,14 @@ export const getBlogById = newsFactory.createHandlers( describeRoute({ description: 'Get any blog post by ID including drafts (requires permission)', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { format: 'uuid', type: 'string' }, + }, + ], responses: { 200: { content: { diff --git a/apps/chronos/src/routes/news/system-messages.ts b/apps/chronos/src/routes/news/system-messages.ts index a3c132d..5aa457c 100644 --- a/apps/chronos/src/routes/news/system-messages.ts +++ b/apps/chronos/src/routes/news/system-messages.ts @@ -76,6 +76,26 @@ export const listSystemMessages = newsFactory.createHandlers( describeRoute({ description: 'List active system messages within date range, filtered by user cohort', + parameters: [ + { + in: 'query', + name: 'limit', + required: false, + schema: { default: 20, minimum: 1, type: 'number' }, + }, + { + in: 'query', + name: 'offset', + required: false, + schema: { default: 0, minimum: 0, type: 'number' }, + }, + { + in: 'query', + name: 'includeExpired', + required: false, + schema: { default: false, type: 'boolean' }, + }, + ], responses: { 200: { content: { @@ -163,6 +183,14 @@ export const listSystemMessages = newsFactory.createHandlers( export const getSystemMessage = newsFactory.createHandlers( describeRoute({ description: 'Get a single system message by ID', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { format: 'uuid', type: 'string' }, + }, + ], responses: { 200: { content: { @@ -290,6 +318,14 @@ export const createSystemMessage = newsFactory.createHandlers( export const updateSystemMessage = newsFactory.createHandlers( describeRoute({ description: 'Update an existing system message', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { format: 'uuid', type: 'string' }, + }, + ], requestBody: { content: { 'application/json': { diff --git a/apps/chronos/src/routes/roles/index.ts b/apps/chronos/src/routes/roles/index.ts index 4fce175..9a72707 100644 --- a/apps/chronos/src/routes/roles/index.ts +++ b/apps/chronos/src/routes/roles/index.ts @@ -1,6 +1,6 @@ import { zValidator } from '@hono/zod-validator'; import { HTTPException } from 'hono/http-exception'; -import { describeRoute } from 'hono-openapi'; +import { describeRoute, resolver } from 'hono-openapi'; import { StatusCodes } from 'http-status-codes'; import z from 'zod'; import type { SuccessResponse } from '#_types/globals'; @@ -13,6 +13,16 @@ export const listPermissions = rolesFactory.createHandlers( description: 'List all known permissions registered by the application', responses: { 200: { + content: { + 'application/json': { + schema: resolver( + z.object({ + data: z.object({ permissions: z.array(z.string()) }), + success: z.literal(true), + }) + ), + }, + }, description: 'List of permissions', }, }, @@ -32,6 +42,23 @@ export const listRoles = rolesFactory.createHandlers( description: 'List all roles with their permissions', responses: { 200: { + content: { + 'application/json': { + schema: resolver( + z.object({ + data: z.object({ + roles: z.array( + z.object({ + can: z.array(z.string()), + name: z.string(), + }) + ), + }), + success: z.literal(true), + }) + ), + }, + }, description: 'List of roles', }, }, @@ -66,11 +93,46 @@ const createRoleSchema = z.object({ permissions: z.array(z.string()).default([]), }); +const updateRoleSchema = z.object({ + permissions: z.array(z.string()), +}); + +const roleResponseSchema = z.object({ + data: z.object({ + can: z.array(z.string()), + name: z.string(), + }), + success: z.literal(true), +}); + +const roleNameParamSchema = z.object({ name: z.string() }); + +const createRoleRequestBodySchema = ( + await resolver(createRoleSchema).toOpenAPISchema() +).schema; + +const updateRoleRequestBodySchema = ( + await resolver(updateRoleSchema).toOpenAPISchema() +).schema; + export const createRole = rolesFactory.createHandlers( describeRoute({ description: 'Create a new role', + requestBody: { + content: { + 'application/json': { + schema: createRoleRequestBodySchema, + }, + }, + description: 'Role details to create.', + }, responses: { 201: { + content: { + 'application/json': { + schema: resolver(roleResponseSchema), + }, + }, description: 'Role created', }, }, @@ -108,15 +170,35 @@ export const createRole = rolesFactory.createHandlers( } ); -const updateRoleSchema = z.object({ - permissions: z.array(z.string()), -}); - export const updateRole = rolesFactory.createHandlers( describeRoute({ description: 'Update permissions for a role', + parameters: [ + { + in: 'path', + name: 'name', + required: true, + schema: { + description: 'Role name to update.', + type: 'string', + }, + }, + ], + requestBody: { + content: { + 'application/json': { + schema: updateRoleRequestBodySchema, + }, + }, + description: 'Permissions payload for the role.', + }, responses: { 200: { + content: { + 'application/json': { + schema: resolver(roleResponseSchema), + }, + }, description: 'Role updated', }, }, @@ -125,7 +207,7 @@ export const updateRole = rolesFactory.createHandlers( requireAuthentication, requireAuthorization('roles:manage'), zValidator('json', updateRoleSchema), - zValidator('param', z.object({ name: z.string() })), + zValidator('param', roleNameParamSchema), async (c) => { const { name: roleName } = c.req.valid('param'); if (!roleName) { @@ -154,8 +236,28 @@ export const updateRole = rolesFactory.createHandlers( export const deleteRole = rolesFactory.createHandlers( describeRoute({ description: 'Delete a role', + parameters: [ + { + in: 'path', + name: 'name', + required: true, + schema: { + description: 'Role name to delete.', + type: 'string', + }, + }, + ], responses: { 200: { + content: { + 'application/json': { + schema: resolver( + z.object({ + success: z.literal(true), + }) + ), + }, + }, description: 'Role deleted', }, }, @@ -163,7 +265,7 @@ export const deleteRole = rolesFactory.createHandlers( }), requireAuthentication, requireAuthorization('roles:manage'), - zValidator('param', z.object({ name: z.string() })), + zValidator('param', roleNameParamSchema), async (c) => { const { name: roleName } = c.req.valid('param'); diff --git a/apps/chronos/src/routes/timetable/_router.ts b/apps/chronos/src/routes/timetable/_router.ts index 956d8e2..5ed2e20 100644 --- a/apps/chronos/src/routes/timetable/_router.ts +++ b/apps/chronos/src/routes/timetable/_router.ts @@ -12,6 +12,7 @@ import { getLessonsForRoom, getLessonsForTeacher, } from '#routes/timetable/lesson'; +import { deleteTimetable, updateTimetable } from '#routes/timetable/manage'; import { createMovedLesson, deleteMovedLesson, @@ -38,6 +39,8 @@ export const timetableRouter = timetableFactory .get('/timetables', ...getAllTimetables) .get('/timetables/latestValid', ...getLatestValidTimetable) .get('/timetables/valid', ...getAllValidTimetables) + .patch('/timetables/:id', ...updateTimetable) + .delete('/timetables/:id', ...deleteTimetable) .post('/import', ...importRoute) // Substitution routes .get('/substitutions', ...getAllSubstitutions) diff --git a/apps/chronos/src/routes/timetable/cohort.ts b/apps/chronos/src/routes/timetable/cohort.ts index 4b93bb3..3024f1b 100644 --- a/apps/chronos/src/routes/timetable/cohort.ts +++ b/apps/chronos/src/routes/timetable/cohort.ts @@ -7,7 +7,7 @@ import { StatusCodes } from 'http-status-codes'; import z from 'zod'; import type { SuccessResponse } from '#_types/globals'; import { db } from '#database'; -import { cohort, timetable } from '#database/schema/timetable'; +import { cohort } from '#database/schema/timetable'; import { requireAuthentication } from '#middleware/auth'; import { createSelectSchema } from '#utils/zod'; import { timetableFactory } from './_factory'; @@ -45,7 +45,7 @@ export const getCohortsForTimetable = timetableFactory.createHandlers( }, tags: ['Cohort'], }), - zValidator('param', z.object({ timetableId: z.uuid() })), + zValidator('param', z.object({ timetableId: z.string() })), requireAuthentication, async (c) => { try { @@ -54,8 +54,7 @@ export const getCohortsForTimetable = timetableFactory.createHandlers( const cohorts = await db .select() .from(cohort) - .leftJoin(timetable, eq(cohort.timetableId, timetable.id)) - .where(eq(timetable.id, timetableId)); + .where(eq(cohort.timetableId, timetableId)); return c.json>({ data: cohorts, diff --git a/apps/chronos/src/routes/timetable/import.ts b/apps/chronos/src/routes/timetable/import.ts index 48aead2..60e2d66 100644 --- a/apps/chronos/src/routes/timetable/import.ts +++ b/apps/chronos/src/routes/timetable/import.ts @@ -22,7 +22,7 @@ const importResponseSchema = z.object({ const importSchema = z.object({ name: z.string(), omanXml: z.file(), - validFrom: z.date(), + validFrom: z.coerce.date(), }); export const importRoute = timetableFactory.createHandlers( diff --git a/apps/chronos/src/routes/timetable/manage.ts b/apps/chronos/src/routes/timetable/manage.ts new file mode 100644 index 0000000..eecea7f --- /dev/null +++ b/apps/chronos/src/routes/timetable/manage.ts @@ -0,0 +1,177 @@ +import { zValidator } from '@hono/zod-validator'; +import { getLogger } from '@logtape/logtape'; +import { eq } from 'drizzle-orm'; +import { HTTPException } from 'hono/http-exception'; +import { describeRoute, resolver } from 'hono-openapi'; +import { StatusCodes } from 'http-status-codes'; +import z from 'zod'; +import type { SuccessResponse } from '#_types/globals'; +import { db } from '#database'; +import { timetable } from '#database/schema/timetable'; +import { requireAuthentication, requireAuthorization } from '#middleware/auth'; +import { createSelectSchema } from '#utils/zod'; +import { timetableFactory } from './_factory'; + +const logger = getLogger(['chronos', 'timetable']); + +const timetableSelectSchema = createSelectSchema(timetable); + +const updateSchema = z.object({ + name: z.string().optional(), + validFrom: z.string().optional(), +}); + +const updateResponseSchema = z.object({ + data: timetableSelectSchema, + success: z.literal(true), +}); + +const deleteResponseSchema = z.object({ + success: z.literal(true), +}); + +export const updateTimetable = timetableFactory.createHandlers( + describeRoute({ + description: 'Update a timetable name and/or validFrom date.', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The unique identifier for the timetable.', + type: 'string', + }, + }, + ], + responses: { + 200: { + content: { + 'application/json': { + schema: resolver(updateResponseSchema), + }, + }, + description: 'Successful Response', + }, + }, + tags: ['Timetable'], + }), + zValidator('param', z.object({ id: z.string() })), + zValidator('json', updateSchema), + requireAuthentication, + requireAuthorization('import:timetable'), + async (c) => { + const { id } = c.req.valid('param'); + const body = c.req.valid('json'); + + try { + const [existing] = await db + .select() + .from(timetable) + .where(eq(timetable.id, id)) + .limit(1); + + if (!existing) { + throw new HTTPException(StatusCodes.NOT_FOUND, { + message: 'Timetable not found', + }); + } + + const updateData: Record = {}; + if (body.name !== undefined) { + updateData.name = body.name; + } + if (body.validFrom !== undefined) { + updateData.validFrom = body.validFrom; + } + + if (Object.keys(updateData).length === 0) { + return c.json>({ + data: existing, + success: true, + }); + } + + const [updated] = await db + .update(timetable) + .set(updateData) + .where(eq(timetable.id, id)) + .returning(); + + return c.json>({ + data: updated, + success: true, + }); + } catch (error) { + if (error instanceof HTTPException) { + throw error; + } + logger.error('Error updating timetable: ', { error }); + throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + message: 'Failed to update timetable', + }); + } + } +); + +export const deleteTimetable = timetableFactory.createHandlers( + describeRoute({ + description: 'Delete a timetable and all associated data.', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'The unique identifier for the timetable.', + type: 'string', + }, + }, + ], + responses: { + 200: { + content: { + 'application/json': { + schema: resolver(deleteResponseSchema), + }, + }, + description: 'Successful Response', + }, + }, + tags: ['Timetable'], + }), + zValidator('param', z.object({ id: z.string() })), + requireAuthentication, + requireAuthorization('import:timetable'), + async (c) => { + const { id } = c.req.valid('param'); + + try { + const [existing] = await db + .select() + .from(timetable) + .where(eq(timetable.id, id)) + .limit(1); + + if (!existing) { + throw new HTTPException(StatusCodes.NOT_FOUND, { + message: 'Timetable not found', + }); + } + + await db.delete(timetable).where(eq(timetable.id, id)); + + return c.json({ + success: true, + }); + } catch (error) { + if (error instanceof HTTPException) { + throw error; + } + logger.error('Error deleting timetable: ', { error }); + throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + message: 'Failed to delete timetable', + }); + } + } +); diff --git a/apps/chronos/src/routes/users/index.ts b/apps/chronos/src/routes/users/index.ts index 1bf5176..79b4e91 100644 --- a/apps/chronos/src/routes/users/index.ts +++ b/apps/chronos/src/routes/users/index.ts @@ -29,6 +29,26 @@ const updateUserBodySchema = ( export const listUsers = usersFactory.createHandlers( describeRoute({ description: 'List users', + parameters: [ + { + in: 'query', + name: 'limit', + required: false, + schema: { default: 20, maximum: 100, minimum: 1, type: 'number' }, + }, + { + in: 'query', + name: 'offset', + required: false, + schema: { default: 0, minimum: 0, type: 'number' }, + }, + { + in: 'query', + name: 'search', + required: false, + schema: { type: 'string' }, + }, + ], responses: { 200: { description: 'List of users', @@ -81,6 +101,17 @@ export const listUsers = usersFactory.createHandlers( export const updateUser = usersFactory.createHandlers( describeRoute({ description: 'Update user', + parameters: [ + { + in: 'path', + name: 'id', + required: true, + schema: { + description: 'User ID to update.', + type: 'string', + }, + }, + ], requestBody: { content: { 'application/json': { schema: updateUserBodySchema }, @@ -95,14 +126,10 @@ export const updateUser = usersFactory.createHandlers( }), requireAuthentication, requireAuthorization('users:manage'), + zValidator('param', z.object({ id: z.string() })), zValidator('json', userUpdatePayload), async (c) => { - const userId = c.req.param('id'); - if (!userId) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'User ID is required', - }); - } + const { id: userId } = c.req.valid('param'); const { nickname, roles } = c.req.valid('json'); const [updatedUser] = await db diff --git a/apps/iris/public/locales/en/translation.json b/apps/iris/public/locales/en/translation.json index ff14782..a5ddcfd 100644 --- a/apps/iris/public/locales/en/translation.json +++ b/apps/iris/public/locales/en/translation.json @@ -9,6 +9,10 @@ "loading": "Loading...", "cancel": "Cancel", "accept": "Accept", + "save": "Save", + "refresh": "Refresh", + "date": "Date", + "actions": "Actions", "back": "Back", "next": "Next", "skip": "Skip", @@ -142,17 +146,31 @@ "filterByClassroom": "Classroom", "printPdf": "Print / PDF", "time": "Time", - "teacherFallback": "Teacher" + "teacherFallback": "Teacher", + "selectTimetable": "Select timetable", + "searchTimetable": "Search timetables...", + "noTimetableFound": "No timetable found", + "activeTimetable": "Current", + "noTimetables": "No timetables found", + "manage": "Manage Timetables", + "manageDescription": "View, edit, and delete imported timetables", + "allTimetables": "All Timetables", + "allTimetablesDescription": "A list of all imported timetables and their validity dates", + "editTimetable": "Edit", + "deleteTimetable": "Delete", + "deleteConfirm": "Delete Timetable", + "deleteDescription": "Are you sure you want to delete \"{{name}}\"? This will permanently remove the timetable and all associated data (cohorts, lessons, etc.). This action cannot be undone.", + "updateSuccess": "Timetable updated successfully", + "updateError": "Failed to update timetable", + "deleteSuccess": "Timetable deleted successfully", + "deleteError": "Failed to delete timetable" }, "substitution": { "title": "Substitutions", "description": "Check out lesson substitutions and cancellations", "create": "Create Substitution", "edit": "Edit Substitution", - "save": "Save", - "refresh": "Refresh", "showPast": "Show past substitutions", - "date": "Date", "datePlaceholder": "Select a date", "substituteTeacher": "Substitute Teacher", "substituteTeacherHint": "Leave as cancelled if the lesson is not being covered.", @@ -160,7 +178,6 @@ "lessons": "Affected Lessons", "affectedLessons": "Lessons", "cohorts": "Cohorts", - "actions": "Actions", "noLessons": "No lessons available", "loadingLessons": "Loading lessons…", "selectCohort": "Filter by Class", @@ -191,10 +208,7 @@ "available": "Moved lessons available", "create": "Create Moved Lesson", "edit": "Edit Moved Lesson", - "save": "Save", - "refresh": "Refresh", "showPast": "Show past", - "date": "Date", "datePlaceholder": "Select a date", "targetDay": "Target Day", "targetPeriod": "Target Period", @@ -203,7 +217,6 @@ "periodLabel": "Period {{num}} ({{start}} – {{end}})", "lessons": "Affected Lessons", "lessonsCount": "Lessons", - "actions": "Actions", "noLessons": "No lessons available", "loadingLessons": "Loading lessons…", "selectCohort": "Filter by Class", @@ -231,16 +244,12 @@ "description": "Manage school announcements and news", "create": "Create Announcement", "edit": "Edit Announcement", - "save": "Save", - "delete": "Delete", "content": "Content", "validFrom": "Valid From", "validUntil": "Valid Until", "cohorts": "Cohorts (Optional)", "noCohorts": "All", "noAnnouncements": "No announcements found", - "actions": "Actions", - "refresh": "Refresh", "createSuccess": "Announcement created successfully", "createError": "Failed to create announcement", "updateSuccess": "Announcement updated successfully", @@ -267,12 +276,9 @@ "title": "Role Management", "name": "Name", "permissions": "Permissions", - "actions": "Actions", "edit": "Edit", - "delete": "Delete", "createRole": "Create Role", "editRole": "Edit Role", - "save": "Save", "create": "Create", "addPermission": "Add", "searchOrAddPermission": "Search permissions...", diff --git a/apps/iris/public/locales/hu/translation.json b/apps/iris/public/locales/hu/translation.json index 9a52e7f..4bb8c95 100644 --- a/apps/iris/public/locales/hu/translation.json +++ b/apps/iris/public/locales/hu/translation.json @@ -131,12 +131,33 @@ "filterByClassroom": "Terem", "printPdf": "Nyomtatás / PDF", "time": "Idő", - "teacherFallback": "Tanár" + "teacherFallback": "Tanár", + "activeTimetable": "Aktuális", + "allTimetables": "Összes órarend", + "allTimetablesDescription": "Az összes importált órarend és érvényességi dátumaik", + "deleteConfirm": "Órarend törlése", + "deleteDescription": "Biztosan törölni akarod a(z) \"{{name}}\" órarendet? Ez véglegesen törli az órarendet és az összes hozzá tartozó adatot (csoportok, órák stb.). Ez a művelet nem vonható vissza.", + "deleteError": "Nem sikerült törölni az órarendet", + "deleteSuccess": "Órarend sikeresen törölve", + "deleteTimetable": "Törlés", + "editTimetable": "Szerkesztés", + "manage": "Órarendek kezelése", + "manageDescription": "Importált órarendek megtekintése, szerkesztése és törlése", + "noTimetableFound": "Nem található órarend", + "noTimetables": "Nincsenek órarendek", + "searchTimetable": "Órarend keresése...", + "selectTimetable": "Órarend kiválasztása", + "updateError": "Nem sikerült frissíteni az órarendet", + "updateSuccess": "Órarend sikeresen frissítve" }, "common": { "cancel": "Mégsem", "loading": "Betöltés...", "accept": "Rendben", + "save": "Mentés", + "refresh": "Frissítés", + "date": "Dátum", + "actions": "Műveletek", "back": "Vissza", "next": "Tovább", "skip": "Kihagyás", @@ -149,10 +170,7 @@ "description": "Órahelyettesítések és elmaradó órák elérése", "create": "Helyettesítés létrehozása", "edit": "Helyettesítés szerkesztése", - "save": "Mentés", - "refresh": "Frissítés", "showPast": "Múltbeli helyettesítések mutatása", - "date": "Dátum", "datePlaceholder": "Válassz egy dátumot", "substituteTeacher": "Helyettesítő tanár", "substituteTeacherHint": "Hagyd elmaradtra, ha az óra nem kerül megtartásra.", @@ -160,7 +178,6 @@ "lessons": "Érintett órák", "affectedLessons": "Órák", "cohorts": "Csoportok", - "actions": "Műveletek", "noLessons": "Nincsenek elérhető órák", "loadingLessons": "Órák betöltése…", "selectCohort": "Szűrés osztály szerint", @@ -191,10 +208,7 @@ "available": "Áthelyezett órák elérhetők", "create": "Áthelyezett óra létrehozása", "edit": "Áthelyezett óra szerkesztése", - "save": "Mentés", - "refresh": "Frissítés", "showPast": "Múltbeliek is", - "date": "Dátum", "datePlaceholder": "Válassz egy dátumot", "targetDay": "Célnap", "targetPeriod": "Célóra", @@ -203,7 +217,6 @@ "periodLabel": "{{num}}. óra ({{start}} – {{end}})", "lessons": "Érintett órák", "lessonsCount": "Órák", - "actions": "Műveletek", "noLessons": "Nincsenek elérhető órák", "loadingLessons": "Órák betöltése…", "selectCohort": "Szűrés osztály szerint", @@ -231,16 +244,12 @@ "description": "Iskolai bejelentések és hírek kezelése", "create": "Bejelentés létrehozása", "edit": "Bejelentés szerkesztése", - "save": "Mentés", - "delete": "Törlés", "content": "Tartalom", "validFrom": "Érvényes ettől", "validUntil": "Érvényes eddig", "cohorts": "Csoportok (opcionális)", "noCohorts": "Minden", "noAnnouncements": "Nincsenek bejelentések", - "actions": "Műveletek", - "refresh": "Frissítés", "createSuccess": "Bejelentés sikeresen létrehozva", "createError": "Nem sikerült létrehozni a bejelentést", "updateSuccess": "Bejelentés sikeresen frissítve", @@ -267,12 +276,9 @@ "title": "Szerepkörök kezelése", "name": "Név", "permissions": "Jogosultságok", - "actions": "Műveletek", "edit": "Szerkesztés", - "delete": "Törlés", "createRole": "Szerepkör létrehozása", "editRole": "Szerepkör szerkesztése", - "save": "Mentés", "create": "Létrehozás", "addPermission": "Hozzáadás", "searchOrAddPermission": "Jogosultságok keresése...", diff --git a/apps/iris/src/components/admin/announcements-dialog.tsx b/apps/iris/src/components/admin/announcements-dialog.tsx index ba6e4bd..2985b04 100644 --- a/apps/iris/src/components/admin/announcements-dialog.tsx +++ b/apps/iris/src/components/admin/announcements-dialog.tsx @@ -219,7 +219,7 @@ export function AnnouncementsDialog({ diff --git a/apps/iris/src/components/admin/moved-lesson-dialog.tsx b/apps/iris/src/components/admin/moved-lesson-dialog.tsx index 66af796..b77d1ef 100644 --- a/apps/iris/src/components/admin/moved-lesson-dialog.tsx +++ b/apps/iris/src/components/admin/moved-lesson-dialog.tsx @@ -270,7 +270,7 @@ export function MovedLessonDialog({ onSubmit={handleSubmit} >
- + - {isCreate ? t('movedLesson.create') : t('movedLesson.save')} + {isCreate ? t('movedLesson.create') : t('common.save')} diff --git a/apps/iris/src/components/admin/role-dialog.tsx b/apps/iris/src/components/admin/role-dialog.tsx index e396ee3..67f7f71 100644 --- a/apps/iris/src/components/admin/role-dialog.tsx +++ b/apps/iris/src/components/admin/role-dialog.tsx @@ -270,7 +270,7 @@ export function RoleDialog({ > {isPending ? t('common.loading') - : t(isEditing ? 'roles.save' : 'roles.create')} + : t(isEditing ? 'common.save' : 'roles.create')} diff --git a/apps/iris/src/components/admin/roles-table.tsx b/apps/iris/src/components/admin/roles-table.tsx index 415ed5f..9c37873 100644 --- a/apps/iris/src/components/admin/roles-table.tsx +++ b/apps/iris/src/components/admin/roles-table.tsx @@ -56,7 +56,7 @@ export function RolesTable({ roles }: RolesTableProps) { {t('roles.name')} {t('roles.permissions')} - {t('roles.actions')} + {t('common.actions')} @@ -103,7 +103,7 @@ export function RolesTable({ roles }: RolesTableProps) { size="sm" variant="destructive" > - {t('roles.delete')} + {t('common.delete')}
diff --git a/apps/iris/src/components/admin/sidebar.tsx b/apps/iris/src/components/admin/sidebar.tsx index 6ae45ef..b2fa273 100644 --- a/apps/iris/src/components/admin/sidebar.tsx +++ b/apps/iris/src/components/admin/sidebar.tsx @@ -50,6 +50,12 @@ export function AdminSidebar() { () => [ { items: [ + { + icon: List, + permission: 'import:timetable', + title: t('timetable.manage'), + url: '/admin/timetable/manage', + }, { icon: Calendar, permission: 'import:timetable', diff --git a/apps/iris/src/components/admin/substitution-dialog.tsx b/apps/iris/src/components/admin/substitution-dialog.tsx index b27af99..b80b55d 100644 --- a/apps/iris/src/components/admin/substitution-dialog.tsx +++ b/apps/iris/src/components/admin/substitution-dialog.tsx @@ -233,7 +233,7 @@ export function SubstitutionDialog({ onSubmit={handleSubmit} >
- + @@ -341,7 +341,7 @@ export function SubstitutionDialog({ type="submit" > - {isCreate ? t('substitution.create') : t('substitution.save')} + {isCreate ? t('substitution.create') : t('common.save')} diff --git a/apps/iris/src/components/timetable/filter-bar.tsx b/apps/iris/src/components/timetable/filter-bar.tsx index 89a07bc..dad48af 100644 --- a/apps/iris/src/components/timetable/filter-bar.tsx +++ b/apps/iris/src/components/timetable/filter-bar.tsx @@ -1,5 +1,6 @@ import { Building2, + CalendarDays, CheckIcon, ChevronsUpDownIcon, GraduationCap, @@ -33,6 +34,12 @@ import type { TeacherItem, } from './types'; +type TimetableItem = { + id: string; + name: string; + validFrom: string | null; +}; + const teacherLabel = (t: TeacherItem, fallback: string): string => `${t.firstName} ${t.lastName}`.trim() || fallback; @@ -109,6 +116,19 @@ const getEmptyMessage = ( return t(messages[activeFilter]); }; +const formatTimetableLabel = (tt: TimetableItem): string => { + if (tt.validFrom) { + const date = new Date(tt.validFrom); + const formatted = date.toLocaleDateString(undefined, { + day: 'numeric', + month: 'short', + year: 'numeric', + }); + return `${tt.name} (${formatted})`; + } + return tt.name; +}; + export function FilterBar({ activeFilter, onFilterChange, @@ -124,6 +144,10 @@ export function FilterBar({ selectorLoading, onPrint, disabled, + timetables, + selectedTimetableId, + currentTimetableId, + onTimetableChange, }: { activeFilter: FilterType; onFilterChange: (value: FilterType) => void; @@ -139,12 +163,17 @@ export function FilterBar({ selectorLoading: boolean; onPrint: () => void; disabled?: boolean; + timetables?: TimetableItem[]; + selectedTimetableId: string | null; + currentTimetableId: string | null; + onTimetableChange: (value: string) => void; }) { const { t } = useTranslation(); const filterSelectId = `filter-${activeFilter}`; const comboboxContentId = `${filterSelectId}-content`; const selectWidthClassName = activeFilter === 'class' ? 'w-50' : 'w-60'; const [comboboxOpen, setComboboxOpen] = useState(false); + const [timetableOpen, setTimetableOpen] = useState(false); const filterOptions = getFilterOptions(activeFilter, { classrooms, @@ -173,6 +202,79 @@ export function FilterBar({ handlers[activeFilter](value); }; + const selectedTimetable = timetables?.find( + (tt) => tt.id === selectedTimetableId + ); + const timetableLabel = selectedTimetable + ? formatTimetableLabel(selectedTimetable) + : t('timetable.selectTimetable'); + const showTimetableSelector = timetables && timetables.length > 1; + + const renderTimetableSelect = () => { + if (!showTimetableSelector) { + return null; + } + + return ( + + + + {timetableLabel} + + + } + /> + + + + + {t('timetable.noTimetableFound')} + + {(timetables ?? []).map((tt) => ( + { + onTimetableChange(tt.id); + setTimetableOpen(false); + }} + value={formatTimetableLabel(tt)} + > + + + {formatTimetableLabel(tt)} + {tt.id === currentTimetableId && ( + + {t('timetable.activeTimetable')} + + )} + + + ))} + + + + + + ); + }; + const renderSelect = () => { if (selectorLoading) { return ; @@ -233,6 +335,7 @@ export function FilterBar({ return (
+ {renderTimetableSelect()} {hasWritePermission && ( + + + + + + {/* Delete Confirmation Dialog */} + { + if (!open) { + setDeleteTarget(null); + } + }} + open={!!deleteTarget} + > + + + {t('timetable.deleteConfirm')} + + {t('timetable.deleteDescription', { + name: deleteTarget?.name, + })} + + + + + + + + +
+ ); +} diff --git a/apps/iris/src/routes/_private/admin/timetable/moved-lessons.tsx b/apps/iris/src/routes/_private/admin/timetable/moved-lessons.tsx index 4856f55..20c5bbd 100644 --- a/apps/iris/src/routes/_private/admin/timetable/moved-lessons.tsx +++ b/apps/iris/src/routes/_private/admin/timetable/moved-lessons.tsx @@ -470,7 +470,7 @@ function MovedLessonsPage() {
{hasWritePermission && ( {hasWritePermission && (