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
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
ci:
runs-on: ubuntu-latest
services:
mariadb:
image: mariadb:11.4
env:
MARIADB_DATABASE: ml_test
MARIADB_USER: ml_test
MARIADB_PASSWORD: ml_test
MARIADB_ROOT_PASSWORD: root
ports:
- 3307:3306
options: >-
--health-cmd "healthcheck.sh --connect --innodb_initialized"
--health-interval 5s
--health-timeout 5s
--health-retries 12
env:
DATABASE_URL: mysql://ml_test:ml_test@127.0.0.1:3307/ml_test
steps:
- uses: actions/checkout@v4
- run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 24
cache: yarn
- run: yarn install --immutable
- run: yarn gen:email
- run: yarn prisma generate
- run: yarn lint
- run: yarn build
- run: yarn typecheck
- run: yarn prisma db push
- run: yarn test
17 changes: 17 additions & 0 deletions compose.test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
services:
mariadb:
image: mariadb:11.4
environment:
MARIADB_DATABASE: ml_test
MARIADB_USER: ml_test
MARIADB_PASSWORD: ml_test
MARIADB_ROOT_PASSWORD: root
ports:
- "3307:3306"
tmpfs:
- /var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 12
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
"start": "node dist/server.js",
"seed": "tsx prisma/seed.ts",
"lint": "eslint .",
"test": "NODE_ENV=test node --import tsx --test --test-force-exit test/*.test.ts"
"test": "NODE_ENV=test node --import tsx --test --test-concurrency=1 --test-force-exit \"test/**/*.test.ts\"",
"test:db:up": "docker compose -f compose.test.yml up -d --wait",
"test:db:down": "docker compose -f compose.test.yml down -v",
"test:db:push": "DATABASE_URL=mysql://ml_test:ml_test@127.0.0.1:3307/ml_test prisma db push",
"test:full": "yarn test:db:up && yarn test:db:push && yarn gen:email && yarn test",
"typecheck": "tsc -p test/tsconfig.json && tsc -p prisma/tsconfig.json"
},
"dependencies": {
"@fastify/autoload": "^6.3.1",
Expand Down
78 changes: 78 additions & 0 deletions prisma/seed-core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Permissions } from "#app/constants/permissions";
import type { RoleName } from "#app/constants/roles";
import type { PrismaClient } from "#app/generated/prisma/client";

const BASE_SHIFT_PERMISSIONS = [
Permissions.EDIT_SHIFT_BASIC,
Permissions.VIEW_SHIFT_BASIC,
Permissions.VIEW_REGISTRATION_BASIC,
Permissions.VIEW_SHIFT_STAFF,
] as const;

const ROLE_PERMISSIONS: Record<RoleName, readonly Permissions[]> = {
root: [
...BASE_SHIFT_PERMISSIONS,
Permissions.VIEW_REGISTRATION_FULL,
Permissions.EDIT_REGISTRATION_PRICE,
Permissions.EDIT_REGISTRATION_IS_REGISTERED,
Permissions.DELETE_REGISTRATION,
Permissions.VIEW_SHIFT_PERMISSIONS,
Permissions.EDIT_SHIFT_MEMBERS,
],
boss: [
...BASE_SHIFT_PERMISSIONS,
Permissions.VIEW_REGISTRATION_PERSONAL_INFO,
Permissions.VIEW_REGISTRATION_PRICE,
Permissions.VIEW_REGISTRATION_CONTACT,
Permissions.EDIT_REGISTRATION_PRICE,
Permissions.EDIT_REGISTRATION_IS_REGISTERED,
Permissions.DELETE_REGISTRATION,
Permissions.VIEW_SHIFT_PERMISSIONS,
Permissions.EDIT_SHIFT_MEMBERS,
],
instructor: [
...BASE_SHIFT_PERMISSIONS,
Permissions.VIEW_REGISTRATION_CONTACT,
],
helper: [...BASE_SHIFT_PERMISSIONS],
"reg-viewer-basic": [Permissions.VIEW_REGISTRATION_BASIC],
};

export const seedRolesAndPermissions = async (
client: PrismaClient,
): Promise<void> => {
const permissionIds = new Map<Permissions, number>();

const getPermissionId = async (permissionName: Permissions) => {
const cached = permissionIds.get(permissionName);
if (cached !== undefined) return cached;

const permission = await client.permission.upsert({
where: { permissionName },
update: {},
create: { permissionName },
});
permissionIds.set(permissionName, permission.id);
return permission.id;
};

for (const roleName of Object.keys(ROLE_PERMISSIONS) as RoleName[]) {
const role = await client.role.upsert({
where: { roleName },
update: {},
create: { roleName },
});

for (const permissionName of ROLE_PERMISSIONS[roleName]) {
const permissionId = await getPermissionId(permissionName);

await client.rolePermission.upsert({
where: {
roleId_permissionId: { roleId: role.id, permissionId },
},
update: {},
create: { roleId: role.id, permissionId },
});
}
}
};
82 changes: 2 additions & 80 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
@@ -1,85 +1,7 @@
import prisma from "#app/lib/prisma";
import { Permissions } from "#app/constants/permissions";
import type { RoleName } from "#app/constants/roles";
import { seedRolesAndPermissions } from "./seed-core";

const BASE_SHIFT_PERMISSIONS = [
Permissions.EDIT_SHIFT_BASIC,
Permissions.VIEW_SHIFT_BASIC,
Permissions.VIEW_REGISTRATION_BASIC,
Permissions.VIEW_SHIFT_STAFF,
] as const;

const ROLE_PERMISSIONS: Record<RoleName, readonly Permissions[]> = {
root: [
...BASE_SHIFT_PERMISSIONS,
Permissions.VIEW_REGISTRATION_FULL,
Permissions.EDIT_REGISTRATION_PRICE,
Permissions.EDIT_REGISTRATION_IS_REGISTERED,
Permissions.DELETE_REGISTRATION,
Permissions.VIEW_SHIFT_PERMISSIONS,
],
boss: [
...BASE_SHIFT_PERMISSIONS,
Permissions.VIEW_REGISTRATION_PERSONAL_INFO,
Permissions.VIEW_REGISTRATION_PRICE,
Permissions.VIEW_REGISTRATION_CONTACT,
Permissions.EDIT_REGISTRATION_PRICE,
Permissions.EDIT_REGISTRATION_IS_REGISTERED,
Permissions.DELETE_REGISTRATION,
Permissions.VIEW_SHIFT_PERMISSIONS,
],
instructor: [
...BASE_SHIFT_PERMISSIONS,
Permissions.VIEW_REGISTRATION_CONTACT,
],
helper: [...BASE_SHIFT_PERMISSIONS],
"reg-viewer-basic": [Permissions.VIEW_REGISTRATION_BASIC],
};

const upsertRole = (roleName: RoleName) =>
prisma.role.upsert({
where: { roleName },
update: {},
create: { roleName },
});

const upsertPermission = (permissionName: Permissions) =>
prisma.permission.upsert({
where: { permissionName },
update: {},
create: { permissionName },
});

const main = async () => {
const permissionIds = new Map<Permissions, number>();

const getPermissionId = async (permissionName: Permissions) => {
const cached = permissionIds.get(permissionName);
if (cached !== undefined) return cached;

const permission = await upsertPermission(permissionName);
permissionIds.set(permissionName, permission.id);
return permission.id;
};

for (const roleName of Object.keys(ROLE_PERMISSIONS) as RoleName[]) {
const role = await upsertRole(roleName);

for (const permissionName of ROLE_PERMISSIONS[roleName]) {
const permissionId = await getPermissionId(permissionName);

await prisma.rolePermission.upsert({
where: {
roleId_permissionId: { roleId: role.id, permissionId },
},
update: {},
create: { roleId: role.id, permissionId },
});
}
}
};

main()
seedRolesAndPermissions(prisma)
.catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
Expand Down
2 changes: 2 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface EnvConfig {
MAILGUN_API_KEY: string;
EMAIL_SERV: string;
DATABASE_HOST: string;
DATABASE_PORT: number;
DATABASE_USER: string;
DATABASE_PASSWORD: string;
DATABASE_NAME: string;
Expand All @@ -34,6 +35,7 @@ export const envSchema: JSONSchemaType<EnvConfig> = {
MAILGUN_API_KEY: { type: "string" },
EMAIL_SERV: { type: "string" },
DATABASE_HOST: { type: "string" },
DATABASE_PORT: { type: "number", default: 3306 },
DATABASE_USER: { type: "string" },
DATABASE_PASSWORD: { type: "string" },
DATABASE_NAME: { type: "string" },
Expand Down
11 changes: 9 additions & 2 deletions src/lib/guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,15 @@ const DEFAULT_MESSAGE = "Puuduvad õigused päringuks.";

// body is parsed and validated before preHandler runs, so reading shiftNr from
// any of these sources is safe.
const getShiftNr = (request: FastifyRequest, source: ShiftNrSource): number =>
(request[source] as { shiftNr: number }).shiftNr;
const getShiftNr = (request: FastifyRequest, source: ShiftNrSource): number => {
const value = (request[source] as { shiftNr?: unknown } | undefined)?.shiftNr;
if (typeof value !== "number" || !Number.isInteger(value)) {
throw new Error(
`Guard misconfiguration: request.${source} does not contain an integer shiftNr`,
);
}
return value;
};

// 403 with { status:"fail", data:{ permissions: message } } when the check fails.
export const requireShiftPermission =
Expand Down
28 changes: 15 additions & 13 deletions src/lib/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,20 @@ export type RegistrationViewFlags = {
contact: boolean;
};

export const deriveRegistrationViewFlags = (
perms: ReadonlySet<string>,
): RegistrationViewFlags => ({
pii:
perms.has(Permissions.VIEW_REGISTRATION_FULL) ||
perms.has(Permissions.VIEW_REGISTRATION_PERSONAL_INFO),
financial:
perms.has(Permissions.VIEW_REGISTRATION_FULL) ||
perms.has(Permissions.VIEW_REGISTRATION_PRICE),
contact:
perms.has(Permissions.VIEW_REGISTRATION_FULL) ||
perms.has(Permissions.VIEW_REGISTRATION_CONTACT),
});

export const getRegistrationViewFlags = async (
userId: number,
shiftNr: number,
Expand All @@ -153,17 +167,5 @@ export const getRegistrationViewFlags = async (
PermissionPrefixes.REGISTRATION_VIEW,
);

const pii =
shiftViewPermissions.has(Permissions.VIEW_REGISTRATION_FULL) ||
shiftViewPermissions.has(Permissions.VIEW_REGISTRATION_PERSONAL_INFO);

const financial =
shiftViewPermissions.has(Permissions.VIEW_REGISTRATION_FULL) ||
shiftViewPermissions.has(Permissions.VIEW_REGISTRATION_PRICE);

const contact =
shiftViewPermissions.has(Permissions.VIEW_REGISTRATION_FULL) ||
shiftViewPermissions.has(Permissions.VIEW_REGISTRATION_CONTACT);

return { pii, financial, contact };
return deriveRegistrationViewFlags(shiftViewPermissions);
};
1 change: 1 addition & 0 deletions src/lib/prisma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "dotenv/config";

const adapter = new PrismaMariaDb({
host: process.env.DATABASE_HOST,
port: Number(process.env.DATABASE_PORT ?? 3306),
user: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
Expand Down
5 changes: 4 additions & 1 deletion src/plugins/app/nodemailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ const mailerPlugin: FastifyPluginAsync = fp(async (server) => {
host: "api.eu.mailgun.net",
};

const transporter = nodemailer.createTransport(mg(config));
const transporter: Transporter =
server.config.NODE_ENV === "test"
? nodemailer.createTransport({ jsonTransport: true })
: nodemailer.createTransport(mg(config));
server.decorate("mailer", transporter);

if (server.config.NODE_ENV !== "test") {
Expand Down
24 changes: 11 additions & 13 deletions src/routes/api/records/records.schemas.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import { Static, Type } from "@sinclair/typebox";

const ShiftRecordsFilterSchema = Type.Object(
{ shiftNr: Type.Integer() },
{ additionalProperties: false },
export const RecordsFetchSchema = Type.Object(
{
shiftNr: Type.Optional(Type.Integer()),
childId: Type.Optional(Type.Integer()),
},
{
additionalProperties: false,
description:
"Filter by shift (current year), by child (all years), or by both. At least one is required.",
anyOf: [{ required: ["shiftNr"] }, { required: ["childId"] }],
},
);

const CamperRecordsFilterSchema = Type.Object(
{ childId: Type.Integer() },
{ additionalProperties: false },
);

export const RecordsFetchSchema = Type.Union([
ShiftRecordsFilterSchema,
CamperRecordsFilterSchema,
]);

export type RecordsFetchQuery = Static<typeof RecordsFetchSchema>;

export const ForceSyncSchema = Type.Object({
Expand Down
Loading
Loading