diff --git a/README.md b/README.md index 77e7c0a..1390516 100644 --- a/README.md +++ b/README.md @@ -134,4 +134,26 @@ All endpoints live under the backend at `/api`. Auth-protected routes require a --- -Made with <3 by [euan](https://github.com/EDRipper) , give it a ⭐ \ No newline at end of file +## Certificates + +This repository includes a certificates feature used to generate, persist and verify participation/fulfilment certificates. + +- Generation: certificates are created server-side when an order is fulfilled (idempotent per order). +- Storage: certificates are persisted in the database with a unique `certificate_number` (format `CERT--`), `recipient_name`, `approved_hours`, `award_item`, and `certificate_text`. +- PDF: certificates are rendered server-side from HTML to PDF (Puppeteer) in landscape A4. + +Key files and routes: +- Backend service: `backend/src/certificates/certificate.service.ts` (generation, HTML/PDF renderer, `syncCertificatesForUser`). +- Backend controller: `backend/src/certificates/certificate.controller.ts` — public verify endpoint `GET /api/certificates/verify/:certificateNumber` and authenticated sync `POST /api/certificates/sync`. +- Frontend verify page: `frontend/src/routes/verify/+page.svelte` and proxy `frontend/src/routes/api/certificates/verify/[certificateNumber]/+server.ts` (public lookup UI). +- Preview template: `frontend/static/example-certificate.html` (visual template used for previewing the certificate design). + +How to use locally: +- Fulfil an order via the backend `ShopService` flow to trigger certificate generation. Generation is idempotent (re-running for the same order will not create duplicates). +- To backfill certificates for an existing user, sign in on the frontend; the site layout calls `POST /api/certificates/sync` to create any missing certificates for fulfilled orders. +- Public verification: open the frontend verify page and enter a certificate number, or call the backend `GET /api/certificates/verify/:certificateNumber` directly. + +Optional: signature images +- The certificate template and generator support swapping the stylized handwritten signature text for actual signature images. Provide image URLs and I can wire them into `example-certificate.html` and `backend/src/certificates/certificate.service.ts`. + +Made with <3 by [euan](https://github.com/EDRipper) , give it a ⭐ diff --git a/backend/Dockerfile b/backend/Dockerfile index ad7fdcb..f4cf3f5 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,5 @@ FROM node:22-alpine AS build +ENV PUPPETEER_SKIP_DOWNLOAD=true WORKDIR /app COPY package*.json ./ RUN npm ci @@ -6,7 +7,9 @@ COPY . . RUN npm run build FROM node:22-alpine -RUN apk add --no-cache curl +RUN apk add --no-cache curl chromium nss freetype harfbuzz ttf-freefont +ENV PUPPETEER_SKIP_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser WORKDIR /app COPY --from=build /app/dist ./dist COPY --from=build /app/package*.json ./ diff --git a/backend/package-lock.json b/backend/package-lock.json index fb7b65d..56c39fb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,6 +20,7 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "pg": "^8.20.0", + "puppeteer": "^25.3.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.31" @@ -2373,6 +2374,186 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@puppeteer/browsers": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.6.tgz", + "integrity": "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA==", + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.7.6", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + }, + "yauzl": { + "optional": true + } + } + }, + "node_modules/@puppeteer/browsers/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@puppeteer/browsers/node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@puppeteer/browsers/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/@puppeteer/browsers/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@puppeteer/browsers/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.48", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", @@ -4244,6 +4425,22 @@ "node": ">=6.0" } }, + "node_modules/chromium-bidi": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", + "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -4723,6 +4920,12 @@ "node": ">=8" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1653615", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1653615.tgz", + "integrity": "sha512-pGVkY3T/qXxAp2nFPodwYqOevk6ncNMSmvL8QfRCx5ZWGd6Vor7AFNmyaA8Zs6uJyP1QAfjuLandCgvSix1BNA==", + "license": "BSD-3-Clause" + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -5683,6 +5886,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -7271,6 +7486,18 @@ "node": ">= 0.8.0" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -7635,6 +7862,21 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/modern-tar": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.7.tgz", + "integrity": "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8407,6 +8649,44 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "25.4.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.4.0.tgz", + "integrity": "sha512-xfQp8dFBcGaLc1hEMaVr7s+oW4ZkAurr8Y9H81ilKhu6QoLfSTkZjU7IavnyJ/VWpB9ni3KNJUQHUatslLWyGw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.6", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1653615", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.4.0", + "typed-query-selector": "^2.12.2" + }, + "bin": { + "puppeteer": "lib/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-core": { + "version": "25.4.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.4.0.tgz", + "integrity": "sha512-K1plkLOdeoUnGeT1OvdqF3qxl33v+Ra/uH5VyPEhXdMcpvGiEskHzxxEU3fgpccJpJLIipB/rPUsvkZRWeKqOA==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.6", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1653615", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/pure-rand": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", @@ -9716,6 +9996,12 @@ "node": ">= 0.4" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "license": "MIT" + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -10197,6 +10483,12 @@ "defaults": "^1.0.3" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", + "license": "Apache-2.0" + }, "node_modules/webpack": { "version": "5.106.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.0.tgz", @@ -10494,6 +10786,27 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -10581,6 +10894,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index 001d24f..0b5614a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -33,6 +33,7 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "pg": "^8.20.0", + "puppeteer": "^25.3.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.31" diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 0b2b58d..7b178b7 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -18,6 +18,7 @@ import { LookoutModule } from './lookout/lookout.module'; import { HcbModule } from './hcb/hcb.module'; import { SidekickModule } from './sidekick/sidekick.module'; import { SiloModule } from './silo/silo.module'; +import { CertificateModule } from './certificates/certificate.module'; import { SettingsModule } from './settings/settings.module'; import { User } from './entities/user.entity'; import { Session } from './entities/session.entity'; @@ -37,6 +38,7 @@ import { Event } from './entities/event.entity'; import { FraudReview } from './entities/fraud-review.entity'; import { HcbCredential } from './entities/hcb-credential.entity'; import { LookoutSession } from './entities/lookout-session.entity'; +import { Certificate } from './entities/certificate.entity'; import { AppSetting } from './entities/app-setting.entity'; import { HealthController } from './health.controller'; @@ -51,7 +53,7 @@ import { HealthController } from './health.controller'; useFactory: (config: ConfigService) => ({ type: 'postgres', url: config.getOrThrow('DATABASE_URL'), - entities: [User, Session, Project, AuditLog, NewsItem, ProjectReview, Comment, ShopItem, Order, FulfillmentUpdate, Submission, ShopSuggestion, ShopSuggestionVote, Devlog, Event, FraudReview, HcbCredential, LookoutSession, AppSetting], + entities: [User, Session, Project, AuditLog, NewsItem, ProjectReview, Comment, ShopItem, Order, FulfillmentUpdate, Submission, ShopSuggestion, ShopSuggestionVote, Devlog, Event, FraudReview, HcbCredential, LookoutSession, Certificate, AppSetting], migrations: [__dirname + '/migrations/*{.ts,.js}'], migrationsRun: true, synchronize: false, @@ -73,6 +75,7 @@ import { HealthController } from './health.controller'; HcbModule, SidekickModule, SiloModule, + CertificateModule, SettingsModule, ], }) diff --git a/backend/src/certificates/certificate.controller.ts b/backend/src/certificates/certificate.controller.ts new file mode 100644 index 0000000..4a832b3 --- /dev/null +++ b/backend/src/certificates/certificate.controller.ts @@ -0,0 +1,211 @@ +import { + Controller, + Get, + Post, + Param, + Req, + UseGuards, + ParseUUIDPipe, + ForbiddenException, + Res, + NotFoundException, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import type { Request, Response } from 'express'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CertificateService } from './certificate.service'; + +@Controller('api/certificates') +export class CertificateController { + private readonly logger = new Logger(CertificateController.name); + + constructor(private readonly certificateService: CertificateService) {} + + @UseGuards(JwtAuthGuard) + @Get('/') + async getUserCertificates(@Req() req: Request) { + const userId = (req as any).user?.uid; + if (!userId) { + throw new ForbiddenException('Unauthorized'); + } + return this.certificateService.getCertificatesForUser(userId); + } + + @UseGuards(JwtAuthGuard) + @Get('/detail/:id') + async getCertificate( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request, + ) { + const userId = (req as any).user?.uid; + if (!userId) { + throw new ForbiddenException('Unauthorized'); + } + + const certificate = await this.certificateService.getCertificateById(id); + + // Ensure user owns this certificate + if (certificate.userId !== userId) { + throw new ForbiddenException('You do not own this certificate'); + } + + return certificate; + } + + @UseGuards(JwtAuthGuard) + @Get('/order/:orderId') + async getCertificateByOrder( + @Param('orderId', ParseUUIDPipe) orderId: string, + @Req() req: Request, + ) { + const userId = (req as any).user?.uid; + if (!userId) { + throw new ForbiddenException('Unauthorized'); + } + + const certificate = await this.certificateService.getCertificateByOrderId(orderId); + if (!certificate) { + return null; + } + + // Ensure user owns this certificate + if (certificate.userId !== userId) { + throw new ForbiddenException('You do not own this certificate'); + } + + return certificate; + } + + @Get('/verify/:certificateNumber') + async verifyCertificate( + @Param('certificateNumber') certificateNumber: string, + ) { + const certificate = await this.certificateService.getCertificateByNumber(certificateNumber.trim()); + if (!certificate) { + throw new NotFoundException('Certificate not found'); + } + + return { + certificateNumber: certificate.certificateNumber, + recipientName: certificate.recipientName, + approvedHours: certificate.approvedHours, + awardItem: certificate.awardItem, + certificateText: certificate.certificateText, + createdAt: certificate.createdAt, + }; + } + + @UseGuards(JwtAuthGuard) + @Post('/sync') + async syncCertificates(@Req() req: Request) { + const userId = (req as any).user?.uid; + if (!userId) { + throw new ForbiddenException('Unauthorized'); + } + + await this.certificateService.syncCertificatesForUser(userId); + return { success: true }; + } + + @UseGuards(JwtAuthGuard) + @Get('/:id/view') + async viewCertificate( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request, + @Res() res: Response, + ) { + const userId = (req as any).user?.uid; + if (!userId) { + throw new ForbiddenException('Unauthorized'); + } + + const certificate = await this.certificateService.getCertificateById(id); + + // Ensure user owns this certificate + if (certificate.userId !== userId) { + throw new ForbiddenException('You do not own this certificate'); + } + + const html = this.certificateService.generateCertificateHtml(certificate); + res.type('text/html').send(html); + } + + @UseGuards(JwtAuthGuard) + @Get('/:id/download') + async downloadCertificate( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request, + @Res() res: Response, + ) { + const userId = (req as any).user?.uid; + if (!userId) { + throw new ForbiddenException('Unauthorized'); + } + + const certificate = await this.certificateService.getCertificateById(id); + + // Ensure user owns this certificate + if (certificate.userId !== userId) { + throw new ForbiddenException('You do not own this certificate'); + } + + const pdf = await this.certificateService.generateCertificatePdf(certificate); + const filename = `Certificate_${certificate.recipientName.replace(/\s+/g, '_')}_${certificate.certificateNumber}.pdf`; + + res + .type('application/pdf') + .setHeader('Content-Disposition', `attachment; filename="${filename}"`) + .send(pdf); + } + + @UseGuards(JwtAuthGuard) + @Get('/:id/thumbnail') + async getThumbnail( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request, + @Res() res: Response, + ) { + const userId = (req as any).user?.uid; + if (!userId) { + throw new ForbiddenException('Unauthorized'); + } + + const certificate = await this.certificateService.getCertificateById(id); + if (certificate.userId !== userId) { + throw new ForbiddenException('You do not own this certificate'); + } + + const cacheDir = path.join(os.tmpdir(), 'beest-cert-thumbs'); + const filename = `${certificate.id}.png`; + const filepath = path.join(cacheDir, filename); + + try { + // ensure cache dir exists + await fs.mkdir(cacheDir, { recursive: true }); + + // if cached file exists, serve it + try { + const stat = await fs.stat(filepath); + if (stat && stat.size > 0) { + const data = await fs.readFile(filepath); + res.type('image/png').setHeader('Cache-Control', 'public, max-age=86400').send(data); + return; + } + } catch (e) { + // file doesn't exist — fallthrough to generate + } + + // generate PNG and cache + const png = await this.certificateService.generateCertificatePng(certificate); + await fs.writeFile(filepath, png); + res.type('image/png').setHeader('Cache-Control', 'public, max-age=86400').send(png); + } catch (error) { + this.logger.error('Failed to produce thumbnail:', error); + throw new InternalServerErrorException('Failed to generate thumbnail'); + } + } +} diff --git a/backend/src/certificates/certificate.module.ts b/backend/src/certificates/certificate.module.ts new file mode 100644 index 0000000..9731cfc --- /dev/null +++ b/backend/src/certificates/certificate.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Certificate } from '../entities/certificate.entity'; +import { Order } from '../entities/order.entity'; +import { User } from '../entities/user.entity'; +import { CertificateService } from './certificate.service'; +import { CertificateController } from './certificate.controller'; +import { AuditLogModule } from '../audit-log/audit-log.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Certificate, Order, User]), + AuditLogModule, + ], + controllers: [CertificateController], + providers: [CertificateService], + exports: [CertificateService], +}) +export class CertificateModule {} diff --git a/backend/src/certificates/certificate.service.ts b/backend/src/certificates/certificate.service.ts new file mode 100644 index 0000000..d3b2819 --- /dev/null +++ b/backend/src/certificates/certificate.service.ts @@ -0,0 +1,576 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as puppeteer from 'puppeteer'; +import { randomUUID } from 'crypto'; +import { existsSync, readFileSync } from 'fs'; +import { resolve } from 'path'; +import { Certificate } from '../entities/certificate.entity'; +import { Order } from '../entities/order.entity'; +import { User } from '../entities/user.entity'; +import { AuditLogService } from '../audit-log/audit-log.service'; + +@Injectable() +export class CertificateService { + private readonly logger = new Logger(CertificateService.name); + + constructor( + @InjectRepository(Certificate) + private readonly certificateRepo: Repository, + @InjectRepository(Order) + private readonly orderRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + private readonly auditLogService: AuditLogService, + ) {} + + /** + * Generate a certificate for a non-granted fulfilled order. + * Called when an order is marked as fulfilled. + * + * Only creates certificates for orders without hcbCardGrantId or siloGrantId. + */ + async generateCertificateForOrder( + orderId: string, + ): Promise { + const existingCertificate = await this.certificateRepo.findOne({ + where: { orderId }, + }); + if (existingCertificate) { + return existingCertificate; + } + + const order = await this.orderRepo.findOne({ + where: { id: orderId }, + relations: ['user'], + }); + + if (!order) { + throw new NotFoundException('Order not found'); + } + + // Only generate certificates for non-granted items + if (order.hcbCardGrantId || order.siloGrantId) { + this.logger.debug( + `Skipping certificate generation for granted order ${orderId}`, + ); + return null; + } + + const user = + order.user || + (await this.userRepo.findOne({ where: { id: order.userId } })); + if (!user) { + throw new NotFoundException('User not found'); + } + // The certificate represents the item price in Pipes, captured at purchase time. + const approvedHours = order.pipesSpent; + + // Generate unique certificate number + const certificateNumber = await this.generateCertificateNumber(); + + // Format the certificate text + const recipientName = user.nickname || user.name || 'Recipient'; + const certificateText = this.formatCertificateText( + recipientName, + approvedHours, + order.itemName, + ); + + // Create and save the certificate + const certificate = this.certificateRepo.create({ + userId: order.userId, + orderId: order.id, + recipientName, + approvedHours, + awardItem: order.itemName, + certificateNumber, + certificateText, + }); + + const saved = await this.certificateRepo.save(certificate); + + await this.auditLogService.log( + order.userId, + 'certificate_generated', + `Certificate generated for order ${order.id}: ${order.itemName}`, + ); + + return saved; + } + + /** + * Backfill certificates for any fulfilled orders that were missed earlier. + * Safe to call on page load because generateCertificateForOrder() is idempotent. + */ + async syncCertificatesForUser(userId: string): Promise { + const fulfilledOrders = await this.orderRepo.find({ + where: { userId, status: 'fulfilled' }, + select: ['id'], + }); + + if (!fulfilledOrders.length) { + return; + } + + const existingCertificates = await this.certificateRepo.find({ + where: { userId }, + select: ['orderId'], + }); + const existingOrderIds = new Set( + existingCertificates.map((c) => c.orderId), + ); + + for (const order of fulfilledOrders) { + if (existingOrderIds.has(order.id)) { + continue; + } + + try { + await this.generateCertificateForOrder(order.id); + } catch (error) { + this.logger.error( + `Failed to backfill certificate for order ${order.id}:`, + error, + ); + } + } + } + + /** + * Generate a unique certificate number + * Format: CERT-YYYY-RANDOM. Randomness avoids collisions during concurrent fulfilments. + */ + private async generateCertificateNumber(): Promise { + const year = new Date().getFullYear(); + return `CERT-${year}-${randomUUID().replace(/-/g, '').slice(0, 12).toUpperCase()}`; + } + + /** + * Format the certificate text + */ + private formatCertificateText( + recipientName: string, + approvedHours: number, + awardItem: string, + ): string { + return `This certificate recognizes ${recipientName}'s fulfilled Beest by Hack Club shop order. ${recipientName} is hereby awarded ${awardItem}, purchased for ${approvedHours} Pipes.`; + } + + /** + * Generate PDF for the certificate + */ + async generateCertificatePdf(certificate: Certificate): Promise { + const html = this.generateCertificateHtml(certificate); + + let browser: puppeteer.Browser | null = null; + try { + browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + + const page = await browser.newPage(); + await page.setContent(html, { waitUntil: 'domcontentloaded' }); + + const pdf = await page.pdf({ + format: 'A4', + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + landscape: true, + }); + + return Buffer.from(pdf); + } catch (error) { + this.logger.error('Failed to generate certificate PDF:', error); + throw new BadRequestException('Failed to generate certificate PDF'); + } finally { + if (browser) { + await browser.close(); + } + } + } + + /** + * Generate a PNG screenshot for the certificate HTML. + */ + async generateCertificatePng(certificate: Certificate): Promise { + const html = this.generateCertificateHtml(certificate); + let browser: puppeteer.Browser | null = null; + try { + browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + + const page = await browser.newPage(); + // reasonable viewport for a certificate thumbnail/full preview + await page.setViewport({ + width: 1400, + height: 900, + deviceScaleFactor: 1, + }); + await page.setContent(html, { waitUntil: 'domcontentloaded' }); + + // capture a PNG of the visible viewport + const png = await page.screenshot({ type: 'png', omitBackground: false }); + return Buffer.from(png); + } catch (error) { + this.logger.error('Failed to generate certificate PNG:', error); + throw new BadRequestException('Failed to generate certificate thumbnail'); + } finally { + if (browser) await browser.close(); + } + } + + /** + * Generate HTML for the certificate (styled e-certificate with logo) + */ + generateLegacyCertificateHtml(certificate: Certificate): string { + const { + recipientName, + approvedHours, + awardItem, + certificateNumber, + createdAt, + } = certificate; + const formattedDate = new Date(createdAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + return ` + + + + + + Certificate of Achievement - ${recipientName} + + + +
+
+
+ +
+
BEEST
+
+ +
Certificate of Achievement
+ +
+ This is to certify that +
+ +
${recipientName}
+ +
+ has successfully completed a project comprising ${approvedHours} approved hours under the Beest by Hack Club program. In recognition of the successful completion of this project, ${recipientName} is hereby awarded ${awardItem} in commendation of their dedication, perseverance, and technical excellence, and in recognition of their exceptional merit and ability. +
+ + +
+
+ + + `; + } + + /** + * The issued certificate template. This intentionally matches + * frontend/static/example-certificate.html, which is the approved design. + */ + generateCertificateHtml(certificate: Certificate): string { + const name = this.escapeHtml(certificate.recipientName); + const award = this.escapeHtml(certificate.awardItem); + const number = this.escapeHtml(certificate.certificateNumber); + const pipes = certificate.approvedHours; + + // Keep issued certificates visually identical to the approved example. + const templatePath = [ + resolve(process.cwd(), 'example-certificate.html'), + resolve(process.cwd(), '..', 'example-certificate.html'), + resolve(__dirname, '..', '..', '..', 'example-certificate.html'), + ].find(existsSync); + + if (templatePath) { + return readFileSync(templatePath, 'utf8') + .replaceAll('Ketan Gupta', name) + .replaceAll('48 approved hours', `${pipes} approved hours`) + .replaceAll('Bambu Lab A1 Mini 3D Printer', award) + .replaceAll('BEEST-YSWS-2024-001', number); + } + + return ` +Beest Certificate — ${name} +
Hack Club flagbeest
Hack Club Recognition
Certificate
This certificate is proudly presented to
${name}
This certificate recognizes ${name}'s fulfilled Beest by Hack Club shop order. ${name} is hereby awarded ${award}, purchased for ${pipes} Pipes.
Certificate No.
${number}
`; + } + + private escapeHtml(value: string): string { + return value.replace( + /[&<>"']/g, + (character) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character]!, + ); + } + + /** + * Get a certificate by ID + */ + async getCertificateById(id: string): Promise { + const certificate = await this.certificateRepo.findOne({ where: { id } }); + if (!certificate) { + throw new NotFoundException('Certificate not found'); + } + return certificate; + } + + /** + * Get all certificates for a user + */ + async getCertificatesForUser(userId: string): Promise { + await this.syncCertificatesForUser(userId); + + return this.certificateRepo.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Get a certificate by order ID + */ + async getCertificateByOrderId(orderId: string): Promise { + return this.certificateRepo.findOne({ where: { orderId } }); + } + + /** + * Get a certificate by its unique certificate number. + */ + async getCertificateByNumber( + certificateNumber: string, + ): Promise { + return this.certificateRepo.findOne({ where: { certificateNumber } }); + } +} diff --git a/backend/src/entities/audit-log.entity.ts b/backend/src/entities/audit-log.entity.ts index 5102856..fa7c5cd 100644 --- a/backend/src/entities/audit-log.entity.ts +++ b/backend/src/entities/audit-log.entity.ts @@ -29,6 +29,7 @@ export const AUDIT_ACTIONS = [ 'order_fulfilled', 'order_refunded', 'order_merged', + 'certificate_generated', 'devlog_created', 'devlog_deleted', 'devlog_reviewed', diff --git a/backend/src/entities/certificate.entity.ts b/backend/src/entities/certificate.entity.ts new file mode 100644 index 0000000..1134428 --- /dev/null +++ b/backend/src/entities/certificate.entity.ts @@ -0,0 +1,59 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from './user.entity'; +import { Order } from './order.entity'; + +@Entity('certificates') +@Index('UQ_certificates_order_id', ['orderId'], { unique: true }) +export class Certificate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ name: 'order_id' }) + orderId: string; + + @ManyToOne(() => Order, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'order_id' }) + order: Order; + + // Recipient name for the certificate + @Column({ name: 'recipient_name', length: 500 }) + recipientName: string; + + // Approved hours completed (fetched from user's projects) + @Column({ name: 'approved_hours', type: 'integer' }) + approvedHours: number; + + // The reward/item name + @Column({ name: 'award_item', type: 'text' }) + awardItem: string; + + // Unique certificate number (e.g., CERT-2025-001-UUID) + @Column({ name: 'certificate_number', length: 64, unique: true }) + certificateNumber: string; + + // Full certificate text + @Column({ name: 'certificate_text', type: 'text' }) + certificateText: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/migrations/1785000000000-CreateCertificates.ts b/backend/src/migrations/1785000000000-CreateCertificates.ts new file mode 100644 index 0000000..7d611a7 --- /dev/null +++ b/backend/src/migrations/1785000000000-CreateCertificates.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateCertificates1785000000000 implements MigrationInterface { + name = 'CreateCertificates1785000000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "certificates" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "user_id" uuid NOT NULL, + "order_id" uuid NOT NULL, + "recipient_name" varchar(500) NOT NULL, + "approved_hours" integer NOT NULL, + "award_item" text NOT NULL, + "certificate_number" varchar(64) NOT NULL UNIQUE, + "certificate_text" text NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_certificates" PRIMARY KEY ("id"), + CONSTRAINT "FK_certificates_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE, + CONSTRAINT "FK_certificates_order" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE, + CONSTRAINT "UQ_certificates_order_id" UNIQUE ("order_id") + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_certificates_user_id" ON "certificates"("user_id")`); + await queryRunner.query(`CREATE INDEX "IDX_certificates_order_id" ON "certificates"("order_id")`); + await queryRunner.query(`CREATE INDEX "IDX_certificates_created_at" ON "certificates"("created_at")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_certificates_created_at"`); + await queryRunner.query(`DROP INDEX "IDX_certificates_order_id"`); + await queryRunner.query(`DROP INDEX "IDX_certificates_user_id"`); + await queryRunner.query(`DROP TABLE "certificates"`); + } +} diff --git a/backend/src/shop/shop.module.ts b/backend/src/shop/shop.module.ts index 5ccbb25..81c190a 100644 --- a/backend/src/shop/shop.module.ts +++ b/backend/src/shop/shop.module.ts @@ -5,6 +5,7 @@ import { AuditLogModule } from '../audit-log/audit-log.module'; import { RsvpModule } from '../rsvp/rsvp.module'; import { SlackModule } from '../slack/slack.module'; import { AttendModule } from '../attend/attend.module'; +import { CertificateModule } from '../certificates/certificate.module'; import { HcaModule } from '../hca/hca.module'; import { ShopItem } from '../entities/shop-item.entity'; import { Order } from '../entities/order.entity'; @@ -23,6 +24,7 @@ import { ShopService } from './shop.service'; RsvpModule, SlackModule, AttendModule, + CertificateModule, HcaModule, ], controllers: [ShopController], diff --git a/backend/src/shop/shop.service.ts b/backend/src/shop/shop.service.ts index 1c3a340..405bc2a 100644 --- a/backend/src/shop/shop.service.ts +++ b/backend/src/shop/shop.service.ts @@ -22,6 +22,7 @@ import { AuditLogService } from '../audit-log/audit-log.service'; import { RsvpService } from '../rsvp/rsvp.service'; import { SlackNotifyService } from '../slack/slack-notify.service'; import { AttendService } from '../attend/attend.service'; +import { CertificateService } from '../certificates/certificate.service'; import { orderPendingDm, orderFulfilledDm, @@ -64,6 +65,7 @@ export class ShopService { private readonly rsvpService: RsvpService, private readonly slackNotify: SlackNotifyService, private readonly attendService: AttendService, + private readonly certificateService: CertificateService, private readonly configService: ConfigService, private readonly hcaService: HcaService, ) {} @@ -757,6 +759,14 @@ export class ShopService { `Order for ${order.quantity}x ${order.itemName} was fulfilled`, ); + // Generate certificate for non-granted orders + try { + await this.certificateService.generateCertificateForOrder(order.id); + } catch (error) { + this.logger.error(`Failed to generate certificate for order ${order.id}:`, error); + // Don't throw - certificate generation is non-critical + } + // Sync fulfillment date to Airtable for Loops this.userRepo.findOne({ where: { id: order.userId }, select: ['email'] }).then((u) => { if (u?.email) this.rsvpService.updateDateField(u.email, 'Loops - beestFulfilledOrder'); diff --git a/docs/certificates/README.md b/docs/certificates/README.md new file mode 100644 index 0000000..57f3a41 --- /dev/null +++ b/docs/certificates/README.md @@ -0,0 +1,21 @@ +# Certificate preview + +This screenshot is the visual test fixture for the issued certificate template. +The backend renders the approved root `example-certificate.html` design. + +![Issued certificate preview](./issued-certificate-preview.png) + +## Certificates page + +The certificates page lives at `/me/certificates`. This screenshot captures its +empty state when the signed-in user has not yet received an eligible fulfilled +shop order. + +![Certificates page empty state](./certificates-page-empty-state.png) + +The preview uses sample certificate data: + +- Recipient: Ketan Gupta +- Award: Arduino Starter Kit +- Cost: 150 Pipes +- Certificate number: CERT-2026-DEMO001 diff --git a/docs/certificates/certificates-page-empty-state.png b/docs/certificates/certificates-page-empty-state.png new file mode 100644 index 0000000..c653a2e Binary files /dev/null and b/docs/certificates/certificates-page-empty-state.png differ diff --git a/docs/certificates/issued-certificate-preview.png b/docs/certificates/issued-certificate-preview.png new file mode 100644 index 0000000..c237387 Binary files /dev/null and b/docs/certificates/issued-certificate-preview.png differ diff --git a/example-certificate.html b/example-certificate.html new file mode 100644 index 0000000..9d4abcb --- /dev/null +++ b/example-certificate.html @@ -0,0 +1,771 @@ + + + + + + + Beest by Hack Club Certificate + + + +
+
+
+
+
+
+
+ +
+ Hack Club flag + Hack Club + beest + YSWS +
+ +
+ Beest sticker +
+ +
+ BUILD. + SHARE. + GROW. +
+ +
h
+ +
+
Hack Club Recognition
+
Certificate
+
Of Excellence
+ +
This certificate is proudly presented to
+
Ketan Gupta
+
+ +
+ This is to certify that Ketan Gupta has successfully completed a project comprising 48 approved hours under the Beest by Hack Club program. In recognition of the successful completion of this project, Ketan Gupta is hereby awarded Bambu Lab A1 Mini 3D Printer in commendation of their dedication, perseverance, and technical excellence, and in recognition of their exceptional merit and ability. +
+ + +
+ +
+
+
Certificate No.
+ BEEST-YSWS-2024-001 +
+
+ + +
+ +
+ + + + diff --git a/frontend/src/routes/+layout.server.ts b/frontend/src/routes/+layout.server.ts new file mode 100644 index 0000000..4fbfec2 --- /dev/null +++ b/frontend/src/routes/+layout.server.ts @@ -0,0 +1,18 @@ +import { getAuthenticatedUser, proxyWithRefresh } from '$lib/server/auth'; +import { env } from '$env/dynamic/private'; +import type { LayoutServerLoad } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const load: LayoutServerLoad = async ({ cookies }) => { + const user = await getAuthenticatedUser(cookies); + if (!user) { + return {}; + } + + await proxyWithRefresh(cookies, `${BACKEND_URL}/api/certificates/sync`, { + method: 'POST' + }); + + return { user }; +}; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 64db65a..8e6af8b 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -73,7 +73,6 @@ }, 1000); return () => { - observer.disconnect(); window.removeEventListener('resize', updateTop); clearTimeout(hintTimer); clearTimeout(animDelay); diff --git a/frontend/src/routes/api/certificates/[id]/[action]/+server.ts b/frontend/src/routes/api/certificates/[id]/[action]/+server.ts new file mode 100644 index 0000000..359dc5f --- /dev/null +++ b/frontend/src/routes/api/certificates/[id]/[action]/+server.ts @@ -0,0 +1,31 @@ +import { env } from '$env/dynamic/private'; +import { tryRefreshToken } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; +const actions = new Set(['view', 'download', 'thumbnail']); + +// Certificate files are HTML, PDF, or PNG. This proxy deliberately streams +// them instead of using the JSON-only application API helper. +export const GET: RequestHandler = async ({ cookies, params }) => { + if (!actions.has(params.action)) return new Response('Not found', { status: 404 }); + + let token = cookies.get('auth_token') ?? await tryRefreshToken(cookies); + if (!token) return new Response(JSON.stringify({ error: 'Not authenticated' }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }); + + const url = `${BACKEND_URL}/api/certificates/${encodeURIComponent(params.id)}/${params.action}`; + let response = await fetch(url, { headers: { authorization: `Bearer ${token}` } }); + if (response.status === 401 && (token = await tryRefreshToken(cookies))) { + response = await fetch(url, { headers: { authorization: `Bearer ${token}` } }); + } + + const headers = new Headers(); + for (const header of ['content-type', 'content-disposition', 'cache-control']) { + const value = response.headers.get(header); + if (value) headers.set(header, value); + } + return new Response(response.body, { status: response.status, headers }); +}; diff --git a/frontend/src/routes/api/certificates/verify/[certificateNumber]/+server.ts b/frontend/src/routes/api/certificates/verify/[certificateNumber]/+server.ts new file mode 100644 index 0000000..e2319ea --- /dev/null +++ b/frontend/src/routes/api/certificates/verify/[certificateNumber]/+server.ts @@ -0,0 +1,34 @@ +import { env } from '$env/dynamic/private'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ params, fetch }) => { + const certificateNumber = params.certificateNumber?.trim(); + if (!certificateNumber) { + return new Response(JSON.stringify({ error: 'Certificate number is required' }), { + status: 400, + headers: { 'content-type': 'application/json' }, + }); + } + + let response: Response; + try { + response = await fetch( + `${BACKEND_URL}/api/certificates/verify/${encodeURIComponent(certificateNumber)}`, + ); + } catch { + return new Response(JSON.stringify({ error: 'Certificate verification is temporarily unavailable' }), { + status: 503, + headers: { 'content-type': 'application/json' }, + }); + } + + const body = await response.text(); + return new Response(body, { + status: response.status, + headers: { + 'content-type': response.headers.get('content-type') ?? 'application/json', + }, + }); +}; diff --git a/frontend/src/routes/home/+page.svelte b/frontend/src/routes/home/+page.svelte index a56b5cf..20733da 100644 --- a/frontend/src/routes/home/+page.svelte +++ b/frontend/src/routes/home/+page.svelte @@ -50,6 +50,7 @@ faq: '/faq', me: '/me', devlogs: '/devlogs', + certificate: '/me/certificates', tutorial: '/tutorial' }; const pathSections: Record = { @@ -61,7 +62,8 @@ '/leaderboard': 'leaderboard', '/faq': 'faq', '/me': 'me', - '/devlogs': 'devlogs' + '/devlogs': 'devlogs', + '/me/certificates': 'certificate' }; const sectionFromPath = (pathname: string) => pathSections[pathname] ?? 'projects'; @@ -1332,9 +1334,12 @@ { id: 'faq', label: 'FAQ', mobile: false, icon: '' }, { id: 'me', label: 'Me', mobile: true, icon: '' }, { id: 'devlogs', label: 'Devlogs', mobile: false, icon: '' }, + { id: 'certificate', label: 'Certificates', mobile: false, icon: '' }, { id: 'tutorial', label: 'Tutorial', mobile: false, icon: '' } ]; + // lazy-load certificate component when user opens the section is handled in navigate() + function loadSectionData(id: string) { if (id === 'shop') { fetchShopItems(); fetchPipes(); fetchUserOrders(); } if (id === 'me') { fetchFulfillmentUpdates(); markFulfillmentRead(); } @@ -1342,6 +1347,10 @@ } function navigate(id: string) { + if (id === 'certificate') { + goto('/me/certificates'); + return; + } if (id === 'tutorial') { goto('/tutorial'); return; } if (creatingProject || editingProject || reviewProject) resetForm(); activeSection = id; diff --git a/frontend/src/routes/me/certificates/+page.server.ts b/frontend/src/routes/me/certificates/+page.server.ts new file mode 100644 index 0000000..1413bb0 --- /dev/null +++ b/frontend/src/routes/me/certificates/+page.server.ts @@ -0,0 +1,20 @@ +import type { PageServerLoad } from './$types'; +import { proxyWithRefresh } from '$lib/server/auth'; +import { env } from '$env/dynamic/private'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const load: PageServerLoad = async ({ cookies, fetch }) => { + const response = await proxyWithRefresh( + cookies, + `${BACKEND_URL}/api/certificates/`, + { method: 'GET' } + ); + + if (!response.ok) { + return { certificates: [] }; + } + + const certificates = await response.json(); + return { certificates }; +}; diff --git a/frontend/src/routes/me/certificates/+page.svelte b/frontend/src/routes/me/certificates/+page.svelte new file mode 100644 index 0000000..c45e996 --- /dev/null +++ b/frontend/src/routes/me/certificates/+page.svelte @@ -0,0 +1,133 @@ + + + + My Certificates · Beest + + + + +
+
+

Beest by Hack Club

+

My certificates

+

Proof of the things you earned through Beest. Each certificate is issued when an eligible shop order is fulfilled.

+
+ +
+ {#if certificates.length} + {#each certificates as cert (cert.id)} +
+
+ + Issued {formatDate(cert.createdAt)} +
+
+

Certificate of achievement

+

{cert.awardItem}

+

Awarded to {cert.recipientName}

+
+
Item price
{cert.approvedHours} Pipes
+
Certificate no.
{cert.certificateNumber}
+
+
+
+ + +
+
+ {/each} + {:else} +
+ +

No certificates yet

+

Certificates appear here after an eligible shop order is fulfilled.

+ Visit the shop +
+ {/if} +
+
+ + diff --git a/frontend/src/routes/verify/+page.svelte b/frontend/src/routes/verify/+page.svelte new file mode 100644 index 0000000..943afd8 --- /dev/null +++ b/frontend/src/routes/verify/+page.svelte @@ -0,0 +1,203 @@ + + + + Verify Certificate - Beest + + + +
+
+
Beest by Hack Club
+

Verify Certificate

+

+ Enter a certificate number to confirm the recipient, fulfilled shop item, and its price in Pipes. +

+ +
{ event.preventDefault(); verifyCertificate(); }}> + + +
+ + {#if error} +
{error}
+ {/if} + + {#if result} +
+

{result.recipientName}

+

Certificate No. {result.certificateNumber}

+

Item price: {result.approvedHours} Pipes

+

Item: {result.awardItem}

+
+ {/if} +
+
+ + diff --git a/frontend/static/example-certificate.html b/frontend/static/example-certificate.html new file mode 100644 index 0000000..88d0a52 --- /dev/null +++ b/frontend/static/example-certificate.html @@ -0,0 +1,109 @@ + + + + + + + Beest by Hack Club Certificate + + + + +
+
+
+ Hack Club flag + beest +
+ +
+
Hack Club Recognition
+
Certificate
+
This certificate is proudly presented to
+
{{NAME}}
+ +
+ This certificate recognizes {{NAME}}'s fulfilled Beest by Hack Club shop order. {{NAME}} is hereby awarded {{AWARD}}, purchased for {{HOURS}} Pipes. +
+ + + +
+ +
+
Certificate No.
+
{{CERTNO}}
+
+
+
+ + + +