Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,454 changes: 3,703 additions & 751 deletions apps/chronos/openapi/chronos-openapi.json

Large diffs are not rendered by default.

4 changes: 0 additions & 4 deletions apps/chronos/src/utils/openapi-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,6 @@ export const openApiSpecOptions = {
required: ['code', 'message', 'status'],
type: 'object',
}),
// The notifications endpoints are internal to the apps: they predate the
// OpenAPI metadata convention, so they are procedures without a public
// description. Drop the `filter` to publish them.
filter: ({ path }) => path[0] !== 'notifications',
info: {
description: 'API for consumption by the Filc app family.',
title: 'Chronos backend API',
Expand Down
15 changes: 1 addition & 14 deletions apps/iris/src/utils/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,11 @@ import type {
} from '@tanstack/react-query';
import { createIsomorphicFn } from '@tanstack/react-start';
import { getRequestHeaders } from '@tanstack/react-start/server';
import { requestOrigin } from '@/utils/request';
import { fallbackApiOrigin, requestOrigin } from '@/utils/request';

/** The API base as a URL prefix: image URLs are built from it by hand. */
export const apiBaseUrl = '/api';

/**
* Absolute origin for a server render whose own origin could not be read.
*
* oRPC builds a `URL` from this value, so a relative one is fatal: the call
* throws `Failed to construct 'URL': Invalid URL` before anything is sent.
* That is why the fallback has to be absolute and not `apiBaseUrl`. In
* development it points straight at Chronos, which is also where the Vite
* proxy sends `/api`; in production the request origin is always available and
* this is only a safety net.
*/
const fallbackApiOrigin =
import.meta.env.VITE_API_ORIGIN ?? 'http://localhost:3001';

const client = createIsomorphicFn()
.client(() =>
createApiClient({
Expand Down
35 changes: 23 additions & 12 deletions apps/iris/src/utils/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ import {
getRequestUrl,
} from '@tanstack/react-start/server';

/**
* Absolute origin for a server render whose own origin could not be read.
*
* A relative origin is fatal for the callers below — oRPC builds a `URL` from
* it, and the browser constructor rejects a relative one outright. The
* development server is exactly that case: `getRequestUrl` answers nothing
* there, so a server render has no origin of its own to use. Development points
* straight at Chronos, which is also where the Vite proxy sends `/api`; behind
* the platform proxy the request origin is always available and this is unused.
*/
export const fallbackApiOrigin =
import.meta.env.VITE_API_ORIGIN ?? 'http://localhost:3001';

/**
* The origin the request being rendered came in on, e.g.
* `https://filc.petrik.hu`.
Expand All @@ -17,19 +30,17 @@ import {
* origin, so parsing it would send a server render's API calls back to
* localhost instead of out to Chronos.
*
* Returns `null` where there is no request to read — outside a server render,
* such as during the build.
* Returns `null` where there is no request origin to read — the development
* server and the build both qualify — so callers use `fallbackApiOrigin`.
*/
export const requestOrigin = () =>
createIsomorphicFn()
.server(() => {
try {
// `xForwardedHost` is off by default in the accessor, and the host is
// the half that matters here: behind the platform proxy it is the
// public origin, while the request object still carries the internal
// one. `null` outside a request — during the build, say.
return getRequestUrl({ xForwardedHost: true, xForwardedProto: true })
.origin;
return getRequestUrl({
xForwardedHost: true,
xForwardedProto: true,
}).origin;
} catch {
return null;
}
Expand All @@ -53,10 +64,10 @@ export const fetchLanguage = () =>
export const fetchSession = (): Promise<AuthSession | null> =>
createIsomorphicFn()
.server(async () => {
const origin = requestOrigin();
if (!origin) {
return null;
}
// Without an origin to read — the development server — the read used to
// bail out, which left every server render looking signed out and sent the
// browser's guard to /auth/login on the next refresh.
const origin = requestOrigin() ?? fallbackApiOrigin;

try {
const response = await fetch(`${origin}/api/auth/get-session`, {
Expand Down
9 changes: 8 additions & 1 deletion apps/iris/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@ export default defineConfig({
tanstackStart(),
viteReact(),
tailwindcss(),
devtools(),
// `consolePiping` is off because the pipe patches `console` in *both* realms
// and feeds each side's output to the other: a server log printed in the
// browser console is then piped back as a client log, and each round embeds
// the previous message — tens of thousands of lines a minute, which also
// stalls hydration. 0.8.5 is the latest release, so re-enable it when
// upstream stops echoing. Server logs still reach the terminal, browser
// logs still reach the browser console.
devtools({ consolePiping: { enabled: false } }),
sentryVitePlugin({
org: 'filc',
project: Bun.env.PROD_DEPLOY ? 'iris' : 'iris-preview',
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/contract/doorlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export const doorlockContract = {
triggerOta: oc
.route(
filcRoute({
auth: true,
description: 'Trigger an OTA update on a specific device',
method: 'POST',
operationId: 'postDoorlockDevicesByIdUpdate',
Expand All @@ -181,6 +182,7 @@ export const doorlockContract = {
updateAll: oc
.route(
filcRoute({
auth: true,
description: 'Trigger an OTA update on all devices',
method: 'POST',
operationId: 'postDoorlockDevicesUpdate',
Expand Down
1 change: 0 additions & 1 deletion packages/api/src/contract/news.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,6 @@ export const newsContract = {
get: oc
.route(
filcRoute({
auth: true,
description: 'Get a single system message by ID',
method: 'GET',
operationId: 'getNewsSystemMessagesById',
Expand Down
167 changes: 147 additions & 20 deletions packages/api/src/contract/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,42 +15,169 @@ import {
updateSettingsSchema,
userPreferencesSchema,
} from '../domains/notifications';
import { filcRoute } from './route';

/**
* The notification inbox and per-user preferences.
* The notification inbox, the per-user preferences and push-token registration.
*
* These procedures deliberately have no `filcRoute` metadata, so the generated
* document filters this subtree out and the endpoints exist only for the typed
* RPC client. The original reason was keeping the document identical to the
* pre-migration one; that document and the `x-filc_*` extensions are gone, so
* this is now a standing choice rather than a constraint — worth revisiting if
* an external client should ever reach the inbox. `GET|POST
* /api/notifications/unsubscribe` is not part of the contract either — it
* Every procedure here is documented, because they are an external-client
* surface: the mobile app registers its FCM token, reads the inbox and edits
* its preferences through them.
*
* The three `*Test*` procedures are development helpers, each refused unless
* `CHRONOS_ENABLE_TEST_NOTIFICATIONS` is set (it defaults to off, and
* `sendTest` takes a free-form address to send real mail to), which is why
* their descriptions say so.
*
* `GET|POST /api/notifications/unsubscribe` is not part of the contract: it
* renders HTML from a plain handler in
* `apps/chronos/src/routes/notifications/unsubscribe-html.ts`.
* `apps/chronos/src/modules/notifications/unsubscribe-html.ts`.
*/
export const notificationsContract = {
fcmTokens: {
register: oc.input(fcmTokenSchema).output(notificationsOkResponseSchema),
register: oc
.route(
filcRoute({
auth: true,
description: 'Register a push (FCM) token for the signed-in user',
method: 'POST',
operationId: 'postNotificationsFcmTokens',
path: '/notifications/fcm-tokens',
tags: ['Notifications'],
})
)
.input(fcmTokenSchema)
.output(notificationsOkResponseSchema),
unregister: oc
.route(
filcRoute({
auth: true,
description: 'Unregister a push (FCM) token',
method: 'DELETE',
operationId: 'deleteNotificationsFcmTokensByToken',
path: '/notifications/fcm-tokens/{token}',
tags: ['Notifications'],
})
)
.input(tokenDeleteSchema)
.output(notificationsOkResponseSchema),
},
/** Paged history for the signed-in user, filtered by type/unread/date. */
list: oc.input(paginationSchema).output(notificationListResponseSchema),
markAllAsRead: oc.output(notificationsOkResponseSchema),
list: oc
.route(
filcRoute({
auth: true,
description: "List the signed-in user's notifications",
method: 'GET',
operationId: 'getNotifications',
path: '/notifications',
tags: ['Notifications'],
})
)
.input(paginationSchema)
.output(notificationListResponseSchema),
markAllAsRead: oc
.route(
filcRoute({
auth: true,
description: 'Mark every notification of the signed-in user as read',
method: 'POST',
operationId: 'postNotificationsReadAll',
path: '/notifications/read-all',
tags: ['Notifications'],
})
)
.output(notificationsOkResponseSchema),
markAsRead: oc
.route(
filcRoute({
auth: true,
description: 'Mark one notification as read',
method: 'POST',
operationId: 'postNotificationsByIdRead',
path: '/notifications/{id}/read',
tags: ['Notifications'],
})
)
.input(notificationIdParamsSchema)
.output(notificationRowSchema),
/** Dev-only: render a notification template without sending it. */
previewTest: oc
.route(
filcRoute({
auth: true,
description:
'Development helper: render a notification template without sending it (needs CHRONOS_ENABLE_TEST_NOTIFICATIONS)',
method: 'POST',
operationId: 'postNotificationsTestPreview',
path: '/notifications/test/preview',
tags: ['Notifications'],
})
)
.input(previewTestNotificationSchema)
.output(previewTestResponseSchema),
/** Dev-only: send a test message over email, in-app and/or push. */
sendTest: oc.input(sendTestNotificationSchema).output(sendTestResultSchema),
settings: oc.output(userPreferencesSchema),
/** Dev-only: drop a test notification into the caller's own inbox. */
test: oc.output(notificationRowSchema),
unreadCount: oc.output(unreadCountResponseSchema),
updateSettings: oc.input(updateSettingsSchema).output(userPreferencesSchema),
sendTest: oc
.route(
filcRoute({
auth: true,
description:
'Development helper: send a test message over email, in-app and/or push (needs CHRONOS_ENABLE_TEST_NOTIFICATIONS)',
method: 'POST',
operationId: 'postNotificationsTestSend',
path: '/notifications/test/send',
tags: ['Notifications'],
})
)
.input(sendTestNotificationSchema)
.output(sendTestResultSchema),
settings: oc
.route(
filcRoute({
auth: true,
description: 'Read the notification preferences of the signed-in user',
method: 'GET',
operationId: 'getNotificationsSettings',
path: '/notifications/settings',
tags: ['Notifications'],
})
)
.output(userPreferencesSchema),
test: oc
.route(
filcRoute({
auth: true,
description:
"Development helper: drop a test notification into the caller's own inbox (needs CHRONOS_ENABLE_TEST_NOTIFICATIONS)",
method: 'POST',
operationId: 'postNotificationsTest',
path: '/notifications/test',
tags: ['Notifications'],
})
)
.output(notificationRowSchema),
unreadCount: oc
.route(
filcRoute({
auth: true,
description: "Count the signed-in user's unread notifications",
method: 'GET',
operationId: 'getNotificationsUnreadCount',
path: '/notifications/unread-count',
tags: ['Notifications'],
})
)
.output(unreadCountResponseSchema),
updateSettings: oc
.route(
filcRoute({
auth: true,
description:
'Update the notification preferences of the signed-in user',
method: 'PATCH',
operationId: 'patchNotificationsSettings',
path: '/notifications/settings',
tags: ['Notifications'],
})
)
.input(updateSettingsSchema)
.output(userPreferencesSchema),
};
5 changes: 5 additions & 0 deletions packages/api/src/contract/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const rolesContract = {
create: oc
.route(
filcRoute({
auth: true,
description: 'Create a new role',
method: 'POST',
operationId: 'postRoles',
Expand All @@ -27,6 +28,7 @@ export const rolesContract = {
delete: oc
.route(
filcRoute({
auth: true,
description: 'Delete a role',
method: 'DELETE',
operationId: 'deleteRolesByName',
Expand All @@ -39,6 +41,7 @@ export const rolesContract = {
list: oc
.route(
filcRoute({
auth: true,
description: 'List all roles with their permissions',
method: 'GET',
operationId: 'getRoles',
Expand All @@ -50,6 +53,7 @@ export const rolesContract = {
permissions: oc
.route(
filcRoute({
auth: true,
description: 'List all known permissions registered by the application',
method: 'GET',
operationId: 'getRolesPermissions',
Expand All @@ -61,6 +65,7 @@ export const rolesContract = {
update: oc
.route(
filcRoute({
auth: true,
description: 'Update permissions for a role',
method: 'PATCH',
operationId: 'patchRolesByName',
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/contract/timetable/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const importContract = {
import: oc
.route(
filcRoute({
auth: true,
description: 'Import a timetable from an Oman or aSc 2012 XML file.',
method: 'POST',
operationId: 'postTimetableImport',
Expand Down
3 changes: 0 additions & 3 deletions packages/api/src/contract/timetable/people.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ export const peopleContract = {
getAllForTimetable: oc
.route(
filcRoute({
auth: true,
description: 'Get cohorts for a given timetable from the database.',
method: 'GET',
operationId: 'getTimetableCohortsGetAllForTimetableByTimetableId',
Expand All @@ -72,7 +71,6 @@ export const peopleContract = {
getForCohort: oc
.route(
filcRoute({
auth: true,
description:
'Get the groups of a cohort, marking the current user selection.',
method: 'GET',
Expand Down Expand Up @@ -116,7 +114,6 @@ export const peopleContract = {
subjects: oc
.route(
filcRoute({
auth: true,
description: 'Get all subjects from the database.',
method: 'GET',
operationId: 'getTimetableSubjects',
Expand Down
Loading
Loading