diff --git a/.gitignore b/.gitignore
index 5c69b1f51..505dc0c04 100644
--- a/.gitignore
+++ b/.gitignore
@@ -398,3 +398,12 @@ Temporary Items
dist
.webpack
.serverless/**/*.zip
+
+# Local SQLite databases (runtime + e2e)
+data.sqlite
+data.sqlite-journal
+test/e2e-*.sqlite
+test/e2e-*.sqlite-journal
+
+# Personal assignment brief (not part of the source)
+NestJS Feature Enhancement Assignment.md
diff --git a/README.md b/README.md
index 8f0f65f7e..138e55d0a 100644
--- a/README.md
+++ b/README.md
@@ -1,98 +1,237 @@
-
-
-
-
-[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
-[circleci-url]: https://circleci.com/gh/nestjs/nest
-
- A progressive Node.js framework for building efficient and scalable server-side applications.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-## Description
-
-[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
-
-## Project setup
+# NestJS Events Feature — Feature Enhancement Assignment
-```bash
-$ npm install
-```
+This repository is a fork of the [nestjs/typescript-starter](https://github.com/nestjs/typescript-starter) that adds a full **Events** feature (per the assignment brief).
+
+It demonstrates a small but realistic NestJS feature: two TypeORM entities (`Event`, `User`), a Many-to-Many relationship, DTO validation, unit tests with mocked repositories, and a true end-to-end test that boots a real SQLite database.
+
+---
+
+## What's in the box
+
+| Layer | File(s) |
+| --- | --- |
+| App bootstrap | [src/main.ts](src/main.ts), [src/app.module.ts](src/app.module.ts) |
+| Events feature | [src/events/event.entity.ts](src/events/event.entity.ts), [src/events/event-status.enum.ts](src/events/event-status.enum.ts), [src/events/dto/create-event.dto.ts](src/events/dto/create-event.dto.ts), [src/events/events.service.ts](src/events/events.service.ts), [src/events/events.controller.ts](src/events/events.controller.ts), [src/events/events.module.ts](src/events/events.module.ts) |
+| Users feature | [src/users/user.entity.ts](src/users/user.entity.ts), [src/users/dto/create-user.dto.ts](src/users/dto/create-user.dto.ts), [src/users/users.service.ts](src/users/users.service.ts), [src/users/users.controller.ts](src/users/users.controller.ts), [src/users/users.module.ts](src/users/users.module.ts) |
+| Unit tests (mocked) | [src/events/events.service.spec.ts](src/events/events.service.spec.ts), [src/events/events.controller.spec.ts](src/events/events.controller.spec.ts), [src/users/users.controller.spec.ts](src/users/users.controller.spec.ts) |
+| E2E tests (real DB) | [test/events.e2e-spec.ts](test/events.e2e-spec.ts) |
+
+---
+
+## REST API
+
+| Method | Path | Body / Params | Description |
+| --- | --- | --- | --- |
+| `POST` | `/users` | `{ "name": "Ada" }` | Create a user. |
+| `GET` | `/users` | – | List all users. |
+| `GET` | `/users/:id` | – | Fetch a user by id. |
+| `POST` | `/users/:id/merge-events` | – | **MergeAll** for that user (UsersController). |
+| `POST` | `/events` | [CreateEventDto](src/events/dto/create-event.dto.ts) | Create an event. |
+| `GET` | `/events` | – | List all events. |
+| `GET` | `/events?userId=:id` | – | List only events that user is invited to. |
+| `GET` | `/events/:id` | – | Fetch an event by id. |
+| `DELETE` | `/events/:id` | – | Delete an event and update invitees' `events` lists. |
+| `POST` | `/events/merge/:userId` | – | **MergeAll** for that user (EventsController). |
+
+> **MergeAll is exposed on both routers** — `POST /users/:id/merge-events` and
+> `POST /events/merge/:userId` both call the same service method.
+
+---
+
+## Data model
+
+**`Event`** — `id` (UUID), `title` (string, required), `description` (string, optional),
+`status` (`TODO` \| `IN_PROGRESS` \| `COMPLETED`), `startTime`, `endTime`,
+`createdAt`, `updatedAt`, `invitees: User[]`.
+
+> **Note on `status` column type:** stored as `varchar(32)` rather than a native
+> SQL ENUM so the same schema runs on SQLite (no native ENUM), MySQL, and
+> PostgreSQL without driver changes. Values are still fully constrained at the
+> application layer via class-validator's `@IsEnum` on `CreateEventDto`.
+
+**`User`** — `id` (UUID), `name`, `events: string[]` (event IDs, per the assignment
+spec), `createdAt`, `updatedAt`, and an inverse `invitedEvents: Event[]` relation.
+The service keeps `events` in sync with the join table so that **MergeAll** can
+quickly find the events to merge for a given user.
-## Compile and run the project
+---
+
+## MergeAll
+
+`POST /events/merge/:userId` (or `POST /users/:id/merge-events`) collapses every
+overlapping event the user is invited to into a single superset event.
+
+Per the assignment FAQ:
+
+- **Mutates the database** — deletes the originals, inserts a merged row, and
+ updates each invitee's `events` list. It is not just a response.
+- **Scalars**: `title` and `description` are appended with `" | "`.
+- **Status heuristic**: `IN_PROGRESS` > `COMPLETED` > `TODO` (most-active state wins).
+- **Invitees** of the merged event are the union of all invitees from the original
+ events.
+
+Example: `E1 = 10:00–11:00 (TODO)` and `E2 = 10:45–12:00 (IN_PROGRESS)` collapse
+to `E_merged = 10:00–12:00 (IN_PROGRESS)` titled `"Standup | 1:1"`.
+
+---
+
+## How to run
```bash
-# development
-$ npm run start
+# 1. Install dependencies
+npm install
-# watch mode
-$ npm run start:dev
+# 2. Start the API (SQLite file auto-created at ./data.sqlite)
+npm run start:dev
-# production mode
-$ npm run start:prod
+# Server listens on http://localhost:3000
+# Override the DB path: DB_PATH=/tmp/myapp.sqlite npm run start:dev
+# Override the port: PORT=4000 npm run start:dev
```
-## Run tests
+### Environment variables
+
+| Variable | Default | Description |
+| --- | --- | --- |
+| `DB_PATH` | `data.sqlite` | Path to the SQLite database file |
+| `PORT` | `3000` | HTTP port the server listens on |
+
+> **Swapping to PostgreSQL or MySQL:** edit `src/app.module.ts` — change
+> `type: 'better-sqlite3'` to `type: 'postgres'` (or `'mysql'`) and supply
+> the usual connection keys. No service or entity changes are required.
+
+---
+
+## How to test
+
+Per assignment FAQ #1 we do **both** — mocked unit tests and a real local-DB e2e test.
```bash
-# unit tests
-$ npm run test
+# Unit tests (mocked TypeORM repositories — no database needed)
+npm test
-# e2e tests
-$ npm run test:e2e
+# End-to-end tests (real SQLite DB, full Nest app + supertest HTTP calls)
+npm run test:e2e
-# test coverage
-$ npm run test:cov
+# Coverage report
+npm run test:cov
```
-## Deployment
+Expected results:
-When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
+| Command | Suites | Tests |
+| --- | --- | --- |
+| `npm test` | 3 | 16 |
+| `npm run test:e2e` | 1 | 4 |
-If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
+---
+
+## Try it with curl
```bash
-$ npm install -g @nestjs/mau
-$ mau deploy
+# Create two users
+curl -s -X POST http://localhost:3000/users -H 'Content-Type: application/json' \
+ -d '{"name":"Ada"}' | tee /tmp/ada.json
+curl -s -X POST http://localhost:3000/users -H 'Content-Type: application/json' \
+ -d '{"name":"Bea"}' | tee /tmp/bea.json
+
+ADA_ID=$(jq -r .id /tmp/ada.json)
+BEA_ID=$(jq -r .id /tmp/bea.json)
+
+# Create two overlapping events
+curl -s -X POST http://localhost:3000/events -H 'Content-Type: application/json' -d "{
+ \"title\": \"Standup\",
+ \"status\": \"TODO\",
+ \"startTime\": \"2026-01-01T10:00:00Z\",
+ \"endTime\": \"2026-01-01T11:00:00Z\",
+ \"invitees\": [\"$ADA_ID\"]
+}"
+
+curl -s -X POST http://localhost:3000/events -H 'Content-Type: application/json' -d "{
+ \"title\": \"1:1\",
+ \"status\": \"IN_PROGRESS\",
+ \"startTime\": \"2026-01-01T10:45:00Z\",
+ \"endTime\": \"2026-01-01T12:00:00Z\",
+ \"invitees\": [\"$ADA_ID\", \"$BEA_ID\"]
+}"
+
+# List only Ada's events
+curl -s "http://localhost:3000/events?userId=$ADA_ID" | jq .
+
+# Merge via EventsController route
+curl -s -X POST "http://localhost:3000/events/merge/$ADA_ID" | jq .
+
+# — or via UsersController route —
+curl -s -X POST "http://localhost:3000/users/$ADA_ID/merge-events" | jq .
+
+# Fetch an event by id
+curl -s "http://localhost:3000/events/" | jq .
+
+# Delete an event
+curl -s -X DELETE "http://localhost:3000/events/" -o /dev/null -w "%{http_code}\n"
```
-With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
+---
-## Resources
+## Stack & decisions
-Check out a few resources that may come in handy when working with NestJS:
+- **NestJS 11** with `@nestjs/typeorm`, `class-validator`, `class-transformer`.
+- **better-sqlite3** as the database driver. Zero setup, real SQL, real FK constraints.
+ Swap to Postgres/MySQL by editing `src/app.module.ts` — no service-level changes required.
+- **TypeORM `synchronize: true`** is enabled for the demo. For production, use TypeORM migrations.
+- **DTO validation** is enabled globally via `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })`.
+- **Status column** is `varchar(32)` (not native ENUM) so the same schema runs on SQLite,
+ MySQL, and PostgreSQL without migration changes. Values are still constrained at the app layer.
+- **Tests** follow the [NestJS testing guide](https://docs.nestjs.com/fundamentals/testing):
+ unit tests use `Test.createTestingModule` with mock repositories; e2e tests use
+ `Test.createTestingModule` against a real SQLite file (deleted after each run).
-- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
-- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
-- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
-- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
-- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
-- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
-- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
-- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
+---
-## Support
+## Project structure
-Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
+```
+.
+├── README.md
+├── eslint.config.mjs
+├── nest-cli.json
+├── package.json
+├── tsconfig.json
+├── tsconfig.build.json
+└── src
+ ├── main.ts
+ ├── app.module.ts
+ ├── events
+ │ ├── event-status.enum.ts
+ │ ├── event.entity.ts
+ │ ├── events.controller.ts ← POST/GET/DELETE + POST merge/:userId
+ │ ├── events.controller.spec.ts ← 6 unit tests
+ │ ├── events.module.ts
+ │ ├── events.service.ts ← create / findById / findAll / findAllForUser / deleteById / mergeAllForUser
+ │ ├── events.service.spec.ts ← 7 unit tests
+ │ └── dto
+ │ └── create-event.dto.ts
+ └── users
+ ├── user.entity.ts
+ ├── users.controller.ts ← POST /users/:id/merge-events
+ ├── users.controller.spec.ts ← 3 unit tests
+ ├── users.module.ts
+ ├── users.service.ts
+ └── dto
+ └── create-user.dto.ts
+└── test
+ ├── events.e2e-spec.ts ← 4 e2e tests (real SQLite)
+ └── jest-e2e.json
+```
-## Stay in touch
+---
-- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
-- Website - [https://nestjs.com](https://nestjs.com/)
-- Twitter - [@nestframework](https://twitter.com/nestframework)
+## Demo video script
-## License
+Suggested 90-second script:
-Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
+1. `npm install` (skip if already installed).
+2. `npm test` — show the **16 passing unit tests** across 3 suites.
+3. `npm run start:dev` in a second terminal — server starts on port 3000.
+4. Run the `curl` block above in order: create users → create events → list user events → merge → fetch → delete.
+5. `npm run test:e2e` — show the **4 passing e2e tests**.
diff --git a/package-lock.json b/package-lock.json
index 36b5307cc..6f248d0a8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,8 +12,13 @@
"@nestjs/common": "^11.0.17",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.1.11",
+ "@nestjs/typeorm": "^11.0.3",
+ "better-sqlite3": "^12.11.1",
+ "class-transformer": "^0.5.1",
+ "class-validator": "^0.15.1",
"reflect-metadata": "^0.2.2",
- "rxjs": "^7.8.1"
+ "rxjs": "^7.8.1",
+ "typeorm": "^1.1.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
@@ -882,7 +887,7 @@
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
@@ -894,7 +899,7 @@
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
@@ -1898,7 +1903,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
"integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==",
- "dev": true,
+ "devOptional": true,
"engines": {
"node": ">=6.0.0"
}
@@ -1926,7 +1931,7 @@
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
@@ -2303,6 +2308,19 @@
}
}
},
+ "node_modules/@nestjs/typeorm": {
+ "version": "11.0.3",
+ "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.3.tgz",
+ "integrity": "sha512-zJ+E5l7auVVA7c0PsvcMdyvRPKTUqU5s2ToYmOA2QEsXQ42qbUGtK4+1HlRfpHqBkCSXP+phiH4luvf9DyJNog==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@nestjs/common": "^10.0.0 || ^11.0.0",
+ "@nestjs/core": "^10.0.0 || ^11.0.0",
+ "reflect-metadata": "^0.1.13 || ^0.2.0",
+ "rxjs": "^7.2.0",
+ "typeorm": "^0.3.0 || ^1.0.0-dev"
+ }
+ },
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@@ -2431,6 +2449,12 @@
"@sinonjs/commons": "^3.0.0"
}
},
+ "node_modules/@sqltools/formatter": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz",
+ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==",
+ "license": "MIT"
+ },
"node_modules/@swc/cli": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@swc/cli/-/cli-0.6.0.tgz",
@@ -2744,25 +2768,25 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
"integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==",
- "dev": true
+ "devOptional": true
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
- "dev": true
+ "devOptional": true
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
- "dev": true
+ "devOptional": true
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
- "dev": true
+ "devOptional": true
},
"node_modules/@types/babel__core": {
"version": "7.20.2",
@@ -2957,7 +2981,7 @@
"version": "22.10.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
"integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"undici-types": "~6.20.0"
}
@@ -3022,6 +3046,12 @@
"@types/superagent": "^8.1.0"
}
},
+ "node_modules/@types/validator": {
+ "version": "13.15.10",
+ "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
+ "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
+ "license": "MIT"
+ },
"node_modules/@types/yargs": {
"version": "17.0.25",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.25.tgz",
@@ -4066,7 +4096,7 @@
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
- "dev": true,
+ "devOptional": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4087,7 +4117,7 @@
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz",
"integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==",
- "dev": true,
+ "devOptional": true,
"engines": {
"node": ">=0.4.0"
}
@@ -4270,7 +4300,7 @@
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
- "dev": true
+ "devOptional": true
},
"node_modules/argparse": {
"version": "2.0.1",
@@ -4442,7 +4472,6 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -4458,6 +4487,20 @@
}
]
},
+ "node_modules/better-sqlite3": {
+ "version": "12.11.1",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
+ "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ },
+ "engines": {
+ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
+ }
+ },
"node_modules/bin-version": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bin-version/-/bin-version-6.0.0.tgz",
@@ -4504,11 +4547,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
- "dev": true,
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
@@ -4619,7 +4670,6 @@
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -4813,6 +4863,12 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
"node_modules/chrome-trace-event": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
@@ -4843,6 +4899,23 @@
"integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==",
"dev": true
},
+ "node_modules/class-transformer": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
+ "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
+ "license": "MIT"
+ },
+ "node_modules/class-validator": {
+ "version": "0.15.1",
+ "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz",
+ "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/validator": "^13.15.3",
+ "libphonenumber-js": "^1.11.1",
+ "validator": "^13.15.22"
+ }
+ },
"node_modules/cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@@ -5165,7 +5238,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
- "dev": true
+ "devOptional": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
@@ -5181,6 +5254,12 @@
"node": ">= 8"
}
},
+ "node_modules/dayjs": {
+ "version": "1.11.21",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
+ "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
+ "license": "MIT"
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -5202,7 +5281,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
- "dev": true,
"dependencies": {
"mimic-response": "^3.1.0"
},
@@ -5217,7 +5295,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
- "dev": true,
"engines": {
"node": ">=10"
},
@@ -5226,10 +5303,10 @@
}
},
"node_modules/dedent": {
- "version": "1.5.1",
- "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
- "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==",
- "dev": true,
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
+ "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==",
+ "license": "MIT",
"peerDependencies": {
"babel-plugin-macros": "^3.1.0"
},
@@ -5239,6 +5316,15 @@
}
}
},
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -5294,6 +5380,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -5317,7 +5412,7 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
- "dev": true,
+ "devOptional": true,
"engines": {
"node": ">=0.3.1"
}
@@ -5405,6 +5500,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
"node_modules/enhanced-resolve": {
"version": "5.17.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz",
@@ -5480,7 +5584,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
- "dev": true,
"engines": {
"node": ">=6"
}
@@ -5791,6 +5894,15 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/expect": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
@@ -5985,6 +6097,12 @@
"node": ">=16.0.0"
}
},
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@@ -6304,6 +6422,12 @@
"node": ">= 0.8"
}
},
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
"node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
@@ -6365,11 +6489,22 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
- "dev": true,
"engines": {
"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.2.7",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
@@ -6426,6 +6561,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -6667,7 +6808,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -6751,6 +6891,12 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
"node_modules/inspect-with-kind": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/inspect-with-kind/-/inspect-with-kind-1.0.5.tgz",
@@ -7730,6 +7876,12 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/libphonenumber-js": {
+ "version": "1.13.9",
+ "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.9.tgz",
+ "integrity": "sha512-VNS5vWMM7r0P66BYv+TQJATxExEgLxN+34hfHDVhDkUsGAE4cRg0shCNSLTXNKm7nIUscC7AfB51TjxEeF7msQ==",
+ "license": "MIT"
+ },
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -7846,7 +7998,7 @@
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
- "dev": true
+ "devOptional": true
},
"node_modules/makeerror": {
"version": "1.0.12",
@@ -8035,6 +8187,12 @@
"mkdirp": "bin/cmd.js"
}
},
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -8112,6 +8270,12 @@
"node": "^18.17.0 || >=20.5.0"
}
},
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -8148,6 +8312,18 @@
"node-gyp-build": "^4.2.2"
}
},
+ "node_modules/node-abi": {
+ "version": "3.94.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
+ "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/node-abort-controller": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
@@ -8609,6 +8785,33 @@
"node": ">=4"
}
},
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -8697,6 +8900,16 @@
"node": ">= 0.10"
}
},
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz",
@@ -8808,6 +9021,30 @@
"node": ">= 0.10"
}
},
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/rc/node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/react-is": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
@@ -9027,7 +9264,6 @@
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -9092,7 +9328,6 @@
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
- "dev": true,
"bin": {
"semver": "bin/semver.js"
},
@@ -9282,6 +9517,51 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"dev": true
},
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -9355,6 +9635,22 @@
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true
},
+ "node_modules/sql-highlight": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/sql-highlight/-/sql-highlight-6.1.0.tgz",
+ "integrity": "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==",
+ "funding": [
+ "https://github.com/scriptcoded/sql-highlight?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/scriptcoded"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/stack-utils": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
@@ -9621,6 +9917,34 @@
"node": ">=6"
}
},
+ "node_modules/tar-fs": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-fs/node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/tar-stream": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
@@ -9801,6 +10125,51 @@
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"dev": true
},
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -9947,7 +10316,7 @@
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -10029,6 +10398,18 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -10070,11 +10451,232 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
+ "node_modules/typeorm": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-1.1.0.tgz",
+ "integrity": "sha512-iX/kvsV42/htCNAQUyElGW87E4Z12neZc6YUFBTEu0BnLiREYDNT5Wfw3wRqZw9vyOEC36zUBAY6Qvywoig06Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@sqltools/formatter": "^1.2.5",
+ "ansis": "^4.3.1",
+ "dayjs": "^1.11.21",
+ "debug": "^4.4.3",
+ "dedent": "^1.7.2",
+ "reflect-metadata": "^0.2.2",
+ "sql-highlight": "^6.1.0",
+ "tinyglobby": "^0.2.17",
+ "tslib": "^2.8.1",
+ "yargs": "^18.0.0"
+ },
+ "bin": {
+ "typeorm": "cli.js",
+ "typeorm-ts-node-commonjs": "cli-ts-node-commonjs.js",
+ "typeorm-ts-node-esm": "cli-ts-node-esm.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.11.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/typeorm"
+ },
+ "peerDependencies": {
+ "@google-cloud/spanner": "^8.0.0",
+ "@sap/hana-client": "^2.14.22",
+ "better-sqlite3": "^12.0.0",
+ "ioredis": "^5.0.4",
+ "mongodb": "^7.0.0",
+ "mssql": "^12.0.0",
+ "mysql2": "^3.15.3",
+ "oracledb": "^6.3.0 || ^7.0.0",
+ "pg": "^8.5.1",
+ "pg-native": "^3.0.0",
+ "pg-query-stream": "^4.0.0",
+ "redis": "^5.0.0 || ^6.0.0",
+ "sql.js": "^1.4.0",
+ "ts-node": "^10.9.2",
+ "typeorm-aurora-data-api-driver": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@google-cloud/spanner": {
+ "optional": true
+ },
+ "@sap/hana-client": {
+ "optional": true
+ },
+ "better-sqlite3": {
+ "optional": true
+ },
+ "ioredis": {
+ "optional": true
+ },
+ "mongodb": {
+ "optional": true
+ },
+ "mssql": {
+ "optional": true
+ },
+ "mysql2": {
+ "optional": true
+ },
+ "oracledb": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ },
+ "pg-native": {
+ "optional": true
+ },
+ "pg-query-stream": {
+ "optional": true
+ },
+ "redis": {
+ "optional": true
+ },
+ "sql.js": {
+ "optional": true
+ },
+ "ts-node": {
+ "optional": true
+ },
+ "typeorm-aurora-data-api-driver": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/typeorm/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/typeorm/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/typeorm/node_modules/ansis": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz",
+ "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/typeorm/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/typeorm/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/typeorm/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/typeorm/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/typeorm/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/typeorm/node_modules/yargs": {
+ "version": "18.0.0",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
+ "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^9.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "string-width": "^7.2.0",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^22.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/typeorm/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/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
- "dev": true,
+ "devOptional": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -10142,7 +10744,7 @@
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
- "dev": true
+ "devOptional": true
},
"node_modules/universalify": {
"version": "2.0.1",
@@ -10210,7 +10812,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
- "dev": true
+ "devOptional": true
},
"node_modules/v8-to-istanbul": {
"version": "9.1.0",
@@ -10232,6 +10834,15 @@
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true
},
+ "node_modules/validator": {
+ "version": "13.15.35",
+ "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
+ "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -10459,7 +11070,6 @@
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
- "dev": true,
"engines": {
"node": ">=10"
}
@@ -10514,7 +11124,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
- "dev": true,
+ "devOptional": true,
"engines": {
"node": ">=6"
}
diff --git a/package.json b/package.json
index 60356431b..5696ce248 100644
--- a/package.json
+++ b/package.json
@@ -26,8 +26,13 @@
"@nestjs/common": "^11.0.17",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.1.11",
+ "@nestjs/typeorm": "^11.0.3",
+ "better-sqlite3": "^12.11.1",
+ "class-transformer": "^0.5.1",
+ "class-validator": "^0.15.1",
"reflect-metadata": "^0.2.2",
- "rxjs": "^7.8.1"
+ "rxjs": "^7.8.1",
+ "typeorm": "^1.1.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts
deleted file mode 100644
index b7bb7163a..000000000
--- a/src/app.controller.spec.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Test, TestingModule } from '@nestjs/testing';
-import { AppController } from './app.controller';
-import { AppService } from './app.service';
-
-describe('AppController', () => {
- let app: TestingModule;
-
- beforeAll(async () => {
- app = await Test.createTestingModule({
- controllers: [AppController],
- providers: [AppService],
- }).compile();
- });
-
- describe('getHello', () => {
- it('should return "Hello World!"', () => {
- const appController = app.get(AppController);
- expect(appController.getHello()).toBe('Hello World!');
- });
- });
-});
diff --git a/src/app.controller.ts b/src/app.controller.ts
deleted file mode 100644
index cce879ee6..000000000
--- a/src/app.controller.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { Controller, Get } from '@nestjs/common';
-import { AppService } from './app.service';
-
-@Controller()
-export class AppController {
- constructor(private readonly appService: AppService) {}
-
- @Get()
- getHello(): string {
- return this.appService.getHello();
- }
-}
diff --git a/src/app.module.ts b/src/app.module.ts
index 86628031c..91db19582 100644
--- a/src/app.module.ts
+++ b/src/app.module.ts
@@ -1,10 +1,20 @@
import { Module } from '@nestjs/common';
-import { AppController } from './app.controller';
-import { AppService } from './app.service';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { Event } from './events/event.entity';
+import { User } from './users/user.entity';
+import { EventsModule } from './events/events.module';
+import { UsersModule } from './users/users.module';
@Module({
- imports: [],
- controllers: [AppController],
- providers: [AppService],
+ imports: [
+ TypeOrmModule.forRoot({
+ type: 'better-sqlite3',
+ database: process.env.DB_PATH ?? 'data.sqlite',
+ entities: [Event, User],
+ synchronize: true, // dev/demo only — fine per the assignment
+ }),
+ EventsModule,
+ UsersModule,
+ ],
})
export class AppModule {}
diff --git a/src/app.service.ts b/src/app.service.ts
deleted file mode 100644
index 927d7cca0..000000000
--- a/src/app.service.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { Injectable } from '@nestjs/common';
-
-@Injectable()
-export class AppService {
- getHello(): string {
- return 'Hello World!';
- }
-}
diff --git a/src/events/dto/create-event.dto.ts b/src/events/dto/create-event.dto.ts
new file mode 100644
index 000000000..afd9a07f9
--- /dev/null
+++ b/src/events/dto/create-event.dto.ts
@@ -0,0 +1,45 @@
+import { Type } from 'class-transformer';
+import {
+ ArrayUnique,
+ IsArray,
+ IsDate,
+ IsEnum,
+ IsOptional,
+ IsString,
+ IsUUID,
+ MaxLength,
+ MinLength,
+} from 'class-validator';
+import { EventStatus } from '../event-status.enum';
+
+export class CreateEventDto {
+ @IsString()
+ @MinLength(1)
+ @MaxLength(255)
+ title!: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(5000)
+ description?: string;
+
+ @IsOptional()
+ @IsEnum(EventStatus, {
+ message: `status must be one of: ${Object.values(EventStatus).join(', ')}`,
+ })
+ status?: EventStatus;
+
+ @Type(() => Date)
+ @IsDate({ message: 'startTime must be a valid ISO date string' })
+ startTime!: Date;
+
+ @Type(() => Date)
+ @IsDate({ message: 'endTime must be a valid ISO date string' })
+ endTime!: Date;
+
+ @IsOptional()
+ @IsArray()
+ @ArrayUnique()
+ @IsUUID('4', { each: true })
+ invitees?: string[];
+}
diff --git a/src/events/event-status.enum.ts b/src/events/event-status.enum.ts
new file mode 100644
index 000000000..716322115
--- /dev/null
+++ b/src/events/event-status.enum.ts
@@ -0,0 +1,5 @@
+export enum EventStatus {
+ TODO = 'TODO',
+ IN_PROGRESS = 'IN_PROGRESS',
+ COMPLETED = 'COMPLETED',
+}
diff --git a/src/events/event.entity.ts b/src/events/event.entity.ts
new file mode 100644
index 000000000..647e7164d
--- /dev/null
+++ b/src/events/event.entity.ts
@@ -0,0 +1,58 @@
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ Index,
+ ManyToMany,
+ PrimaryGeneratedColumn,
+ UpdateDateColumn,
+} from 'typeorm';
+import { EventStatus } from './event-status.enum';
+import { User } from '../users/user.entity';
+
+/**
+ * `status` is stored as varchar(32) rather than a native SQL ENUM so that
+ * the same schema works with SQLite (no native ENUM type), MySQL, and
+ * PostgreSQL without a migration change. TypeORM's native `enum` column
+ * type is Postgres-only and breaks on SQLite. Values are still fully
+ * constrained at the application layer via the EventStatus enum and
+ * class-validator's @IsEnum decorator on CreateEventDto.
+ *
+ * To use a native Postgres ENUM instead, change the column definition to:
+ * @Column({ type: 'enum', enum: EventStatus, default: EventStatus.TODO })
+ * and remove this comment.
+ */
+@Entity({ name: 'events' })
+@Index(['startTime', 'endTime'])
+export class Event {
+ @PrimaryGeneratedColumn('uuid')
+ id!: string;
+
+ @Column({ type: 'varchar', length: 255 })
+ title!: string;
+
+ @Column({ type: 'text', nullable: true })
+ description?: string | null;
+
+ @Column({
+ type: 'varchar',
+ length: 32,
+ default: EventStatus.TODO,
+ })
+ status!: EventStatus;
+
+ @Column({ type: 'datetime' })
+ startTime!: Date;
+
+ @Column({ type: 'datetime' })
+ endTime!: Date;
+
+ @CreateDateColumn()
+ createdAt!: Date;
+
+ @UpdateDateColumn()
+ updatedAt!: Date;
+
+ @ManyToMany(() => User, (user) => user.invitedEvents)
+ invitees?: User[];
+}
diff --git a/src/events/events.controller.spec.ts b/src/events/events.controller.spec.ts
new file mode 100644
index 000000000..6148d2f35
--- /dev/null
+++ b/src/events/events.controller.spec.ts
@@ -0,0 +1,121 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { getRepositoryToken } from '@nestjs/typeorm';
+import { EventsController } from './events.controller';
+import { EventsService } from './events.service';
+import { Event } from './event.entity';
+import { User } from '../users/user.entity';
+import { EventStatus } from './event-status.enum';
+
+describe('EventsController', () => {
+ let controller: EventsController;
+ let service: EventsService;
+
+ const eventRepoMock = {
+ rows: new Map(),
+ create: jest.fn((d) => d),
+ save: jest.fn(),
+ findOne: jest.fn(),
+ find: jest.fn(),
+ delete: jest.fn(),
+ remove: jest.fn(),
+ };
+ const userRepoMock = {
+ rows: new Map(),
+ create: jest.fn((d) => d),
+ save: jest.fn(),
+ findOne: jest.fn(),
+ find: jest.fn(),
+ };
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+
+ const moduleRef: TestingModule = await Test.createTestingModule({
+ controllers: [EventsController],
+ providers: [
+ EventsService,
+ { provide: getRepositoryToken(Event), useValue: eventRepoMock },
+ { provide: getRepositoryToken(User), useValue: userRepoMock },
+ ],
+ }).compile();
+
+ controller = moduleRef.get(EventsController);
+ service = moduleRef.get(EventsService);
+
+ jest
+ .spyOn(service, 'create')
+ .mockImplementation(async (dto) =>
+ ({
+ id: 'fixed-id',
+ title: dto.title,
+ description: dto.description ?? null,
+ status: dto.status ?? EventStatus.TODO,
+ startTime: dto.startTime,
+ endTime: dto.endTime,
+ }) as unknown as Event,
+ );
+
+ jest
+ .spyOn(service, 'findAll')
+ .mockResolvedValue([{ id: 'ev-all' } as unknown as Event]);
+
+ jest
+ .spyOn(service, 'findAllForUser')
+ .mockResolvedValue([{ id: 'ev-user' } as unknown as Event]);
+
+ jest
+ .spyOn(service, 'findById')
+ .mockImplementation(async (id) => ({ id } as unknown as Event));
+
+ jest
+ .spyOn(service, 'deleteById')
+ .mockImplementation(async () => undefined);
+
+ jest
+ .spyOn(service, 'mergeAllForUser')
+ .mockResolvedValue([{ id: 'merged-id' } as unknown as Event]);
+ });
+
+ it('POST /events -> service.create()', async () => {
+ const dto = {
+ title: 'x',
+ startTime: new Date(),
+ endTime: new Date(Date.now() + 3_600_000),
+ };
+ const result = await controller.create(dto as never);
+ expect(service.create).toHaveBeenCalledWith(dto);
+ expect(result.id).toBe('fixed-id');
+ });
+
+ it('GET /events (no userId) -> service.findAll()', async () => {
+ const result = await controller.findAll(undefined);
+ expect(service.findAll).toHaveBeenCalled();
+ expect(service.findAllForUser).not.toHaveBeenCalled();
+ expect(result[0].id).toBe('ev-all');
+ });
+
+ it('GET /events?userId=u-1 -> service.findAllForUser()', async () => {
+ const result = await controller.findAll('u-1');
+ expect(service.findAllForUser).toHaveBeenCalledWith('u-1');
+ expect(service.findAll).not.toHaveBeenCalled();
+ expect(result[0].id).toBe('ev-user');
+ });
+
+ it('GET /events/:id -> service.findById()', async () => {
+ const result = await controller.findOne('abc');
+ expect(service.findById).toHaveBeenCalledWith('abc');
+ expect(result.id).toBe('abc');
+ });
+
+ it('DELETE /events/:id -> service.deleteById()', async () => {
+ await controller.remove('abc');
+ expect(service.deleteById).toHaveBeenCalledWith('abc');
+ });
+
+ it('POST /events/merge/:userId -> service.mergeAllForUser()', async () => {
+ const result = await controller.mergeAll('u-99');
+ expect(service.mergeAllForUser).toHaveBeenCalledWith('u-99');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('merged-id');
+ });
+});
diff --git a/src/events/events.controller.ts b/src/events/events.controller.ts
new file mode 100644
index 000000000..a4a6f8870
--- /dev/null
+++ b/src/events/events.controller.ts
@@ -0,0 +1,65 @@
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ HttpCode,
+ HttpStatus,
+ Param,
+ ParseUUIDPipe,
+ Post,
+ Query,
+} from '@nestjs/common';
+import { EventsService } from './events.service';
+import { CreateEventDto } from './dto/create-event.dto';
+import { Event } from './event.entity';
+
+@Controller('events')
+export class EventsController {
+ constructor(private readonly eventsService: EventsService) {}
+
+ @Post()
+ @HttpCode(HttpStatus.CREATED)
+ create(@Body() createEventDto: CreateEventDto): Promise {
+ return this.eventsService.create(createEventDto);
+ }
+
+ /**
+ * GET /events — returns all events
+ * GET /events?userId=:id — returns only events the user is invited to
+ */
+ @Get()
+ findAll(@Query('userId') userId?: string): Promise {
+ if (userId) {
+ return this.eventsService.findAllForUser(userId);
+ }
+ return this.eventsService.findAll();
+ }
+
+ @Get(':id')
+ findOne(@Param('id', new ParseUUIDPipe()) id: string): Promise {
+ return this.eventsService.findById(id);
+ }
+
+ @Delete(':id')
+ @HttpCode(HttpStatus.NO_CONTENT)
+ remove(@Param('id', new ParseUUIDPipe()) id: string): Promise {
+ return this.eventsService.deleteById(id);
+ }
+
+ /**
+ * POST /events/merge/:userId
+ *
+ * MergeAll — collapses every pair of overlapping events for the given
+ * user into a single superset event. Mutates the database per
+ * assignment FAQ #2.
+ * Also available as POST /users/:id/merge-events on UsersController.
+ */
+ @Post('merge/:userId')
+ @HttpCode(HttpStatus.OK)
+ mergeAll(
+ @Param('userId', new ParseUUIDPipe()) userId: string,
+ ): Promise {
+ return this.eventsService.mergeAllForUser(userId);
+ }
+}
diff --git a/src/events/events.module.ts b/src/events/events.module.ts
new file mode 100644
index 000000000..5fed862f8
--- /dev/null
+++ b/src/events/events.module.ts
@@ -0,0 +1,14 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { EventsService } from './events.service';
+import { EventsController } from './events.controller';
+import { Event } from './event.entity';
+import { User } from '../users/user.entity';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([Event, User])],
+ controllers: [EventsController],
+ providers: [EventsService],
+ exports: [EventsService, TypeOrmModule],
+})
+export class EventsModule {}
diff --git a/src/events/events.service.spec.ts b/src/events/events.service.spec.ts
new file mode 100644
index 000000000..4cb53b0c0
--- /dev/null
+++ b/src/events/events.service.spec.ts
@@ -0,0 +1,294 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { getRepositoryToken } from '@nestjs/typeorm';
+import {
+ BadRequestException,
+ NotFoundException,
+} from '@nestjs/common';
+import { EventsService } from './events.service';
+import { Event } from './event.entity';
+import { User } from '../users/user.entity';
+import { EventStatus } from './event-status.enum';
+import { CreateEventDto } from './dto/create-event.dto';
+
+/* ------------------------------------------------------------------ */
+/* Test doubles */
+/* ------------------------------------------------------------------ */
+
+type EventRow = Event & { id: string };
+type UserRow = User & { id: string; events: string[] };
+
+function makeEventRepo() {
+ const rows = new Map();
+ return {
+ rows,
+ create: jest.fn((data: Partial): EventRow => {
+ return { ...(data as EventRow) };
+ }),
+ save: jest.fn(async (entity: EventRow): Promise => {
+ if (!entity.id) {
+ entity.id = `ev-${rows.size + 1}-${Date.now()}`;
+ }
+ rows.set(entity.id, entity);
+ return entity;
+ }),
+ findOne: jest.fn(async (opts: { where: { id: string }; relations?: unknown }) => {
+ const found = rows.get(opts.where.id);
+ if (!found) return null;
+ return { ...found };
+ }),
+ find: jest.fn(async () => Array.from(rows.values())),
+ delete: jest.fn(async (criteria: string[] | { id: string }[]) => {
+ for (const c of criteria) {
+ const id = typeof c === 'string' ? c : c.id;
+ rows.delete(id);
+ }
+ return { affected: (criteria as unknown[]).length };
+ }),
+ remove: jest.fn(async (entity: EventRow) => {
+ rows.delete(entity.id);
+ return entity;
+ }),
+ };
+}
+
+function makeUserRepo() {
+ const rows = new Map();
+ return {
+ rows,
+ create: jest.fn((data: Partial): UserRow => {
+ return { ...(data as UserRow) } as UserRow;
+ }),
+ save: jest.fn(async (entity: UserRow): Promise => {
+ if (!entity.id) {
+ entity.id = `u-${rows.size + 1}-${Date.now()}`;
+ }
+ rows.set(entity.id, entity);
+ return entity;
+ }),
+ findOne: jest.fn(async (opts: { where: { id: string } }) => {
+ const found = rows.get(opts.where.id);
+ return found ? { ...found } : null;
+ }),
+ find: jest.fn(async (opts: { where: { id: { _value: string[] } } } = { where: { id: { _value: [] } } }) => {
+ const ids: string[] = (opts.where?.id as unknown as { _value: string[] })?._value ?? [];
+ if (ids.length === 0) return [];
+ return Array.from(rows.values()).filter((u) => ids.includes(u.id));
+ }),
+ };
+}
+
+/* ------------------------------------------------------------------ */
+/* Tests */
+/* ------------------------------------------------------------------ */
+
+describe('EventsService', () => {
+ let service: EventsService;
+ let eventRepo: ReturnType;
+ let userRepo: ReturnType;
+
+ beforeEach(async () => {
+ eventRepo = makeEventRepo();
+ userRepo = makeUserRepo();
+
+ const moduleRef: TestingModule = await Test.createTestingModule({
+ providers: [
+ EventsService,
+ { provide: getRepositoryToken(Event), useValue: eventRepo },
+ { provide: getRepositoryToken(User), useValue: userRepo },
+ ],
+ }).compile();
+
+ service = moduleRef.get(EventsService);
+ });
+
+ describe('create', () => {
+ it('rejects an endTime <= startTime', async () => {
+ const dto: CreateEventDto = {
+ title: 'Bad',
+ startTime: new Date('2026-01-01T10:00:00Z'),
+ endTime: new Date('2026-01-01T10:00:00Z'),
+ } as unknown as CreateEventDto;
+
+ await expect(service.create(dto)).rejects.toBeInstanceOf(
+ BadRequestException,
+ );
+ });
+
+ it('throws NotFound when an invitee id does not exist', async () => {
+ const dto: CreateEventDto = {
+ title: 'Demo',
+ startTime: new Date('2026-01-01T10:00:00Z'),
+ endTime: new Date('2026-01-01T11:00:00Z'),
+ invitees: ['ghost-id'],
+ } as unknown as CreateEventDto;
+
+ await expect(service.create(dto)).rejects.toBeInstanceOf(
+ NotFoundException,
+ );
+ });
+
+ it('persists the event and appends the new id to every invitee', async () => {
+ const user: UserRow = {
+ id: 'u-1',
+ name: 'Ada',
+ events: [],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ } as UserRow;
+ userRepo.rows.set(user.id, user);
+
+ const dto: CreateEventDto = {
+ title: 'Sync',
+ startTime: new Date('2026-01-01T10:00:00Z'),
+ endTime: new Date('2026-01-01T11:00:00Z'),
+ invitees: [user.id],
+ } as unknown as CreateEventDto;
+
+ const result = await service.create(dto);
+ expect(result).toBeDefined();
+ expect(result.id).toBeDefined();
+ expect(userRepo.rows.get(user.id)!.events).toEqual([result.id]);
+ });
+ });
+
+ describe('findById / deleteById', () => {
+ it('throws NotFound when the event does not exist', async () => {
+ await expect(service.findById('nope')).rejects.toBeInstanceOf(
+ NotFoundException,
+ );
+ });
+
+ it('deletes the event and removes the id from invitees', async () => {
+ const user: UserRow = {
+ id: 'u-1',
+ name: 'Ada',
+ events: [],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ } as UserRow;
+ userRepo.rows.set(user.id, user);
+
+ const ev: EventRow = {
+ id: 'e-1',
+ title: 'X',
+ status: EventStatus.TODO,
+ startTime: new Date('2026-01-01T10:00:00Z'),
+ endTime: new Date('2026-01-01T11:00:00Z'),
+ invitees: [user],
+ } as EventRow;
+ eventRepo.rows.set(ev.id, ev);
+ user.events = [ev.id];
+
+ await service.deleteById(ev.id);
+ expect(eventRepo.rows.has(ev.id)).toBe(false);
+ expect(userRepo.rows.get(user.id)!.events).toEqual([]);
+ });
+ });
+
+ describe('mergeAllForUser', () => {
+ async function seedUserWithTwoOverlappingEvents() {
+ const user: UserRow = {
+ id: 'u-1',
+ name: 'Ada',
+ events: [],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ } as UserRow;
+ userRepo.rows.set(user.id, user);
+
+ const e1: EventRow = {
+ id: 'e-1',
+ title: 'Standup',
+ status: EventStatus.TODO,
+ startTime: new Date('2026-01-01T10:00:00Z'),
+ endTime: new Date('2026-01-01T11:00:00Z'),
+ invitees: [user],
+ } as EventRow;
+ const e2: EventRow = {
+ id: 'e-2',
+ title: '1:1',
+ status: EventStatus.IN_PROGRESS,
+ startTime: new Date('2026-01-01T10:45:00Z'),
+ endTime: new Date('2026-01-01T12:00:00Z'),
+ invitees: [user],
+ } as EventRow;
+ eventRepo.rows.set(e1.id, e1);
+ eventRepo.rows.set(e2.id, e2);
+ user.events = [e1.id, e2.id];
+ return { user, e1, e2 };
+ }
+
+ it('merges two overlapping events into one spanning the full range', async () => {
+ const { user, e1, e2 } = await seedUserWithTwoOverlappingEvents();
+
+ const merged = await service.mergeAllForUser(user.id);
+
+ expect(merged).toHaveLength(1);
+ const m = merged[0];
+ expect(m.startTime.toISOString()).toBe(e1.startTime.toISOString());
+ expect(m.endTime.toISOString()).toBe(e2.endTime.toISOString());
+ expect(m.title).toContain('Standup');
+ expect(m.title).toContain('1:1');
+ expect(m.status).toBe(EventStatus.IN_PROGRESS); // most-active wins
+
+ // Both originals are gone.
+ expect(eventRepo.rows.has(e1.id)).toBe(false);
+ expect(eventRepo.rows.has(e2.id)).toBe(false);
+
+ // The user's `events` list now points to the new merged id.
+ const refreshedUser = userRepo.rows.get(user.id)!;
+ expect(refreshedUser.events).toHaveLength(1);
+ expect(refreshedUser.events[0]).toBe(m.id);
+ });
+
+ it('leaves a non-overlapping event untouched', async () => {
+ const user: UserRow = {
+ id: 'u-2',
+ name: 'Bea',
+ events: [],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ } as UserRow;
+ userRepo.rows.set(user.id, user);
+
+ const e1: EventRow = {
+ id: 'e-A',
+ title: 'Morning',
+ status: EventStatus.TODO,
+ startTime: new Date('2026-01-01T08:00:00Z'),
+ endTime: new Date('2026-01-01T09:00:00Z'),
+ invitees: [user],
+ } as EventRow;
+ const e2: EventRow = {
+ id: 'e-B',
+ title: 'Afternoon',
+ status: EventStatus.TODO,
+ startTime: new Date('2026-01-01T14:00:00Z'),
+ endTime: new Date('2026-01-01T15:00:00Z'),
+ invitees: [user],
+ } as EventRow;
+ eventRepo.rows.set(e1.id, e1);
+ eventRepo.rows.set(e2.id, e2);
+ user.events = [e1.id, e2.id];
+
+ const merged = await service.mergeAllForUser(user.id);
+ expect(merged).toHaveLength(2);
+ expect(eventRepo.rows.has(e1.id)).toBe(true);
+ expect(eventRepo.rows.has(e2.id)).toBe(true);
+ });
+
+ it('returns [] when the user has no events', async () => {
+ const user: UserRow = {
+ id: 'u-3',
+ name: 'Cy',
+ events: [],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ } as UserRow;
+ userRepo.rows.set(user.id, user);
+
+ const merged = await service.mergeAllForUser(user.id);
+ expect(merged).toEqual([]);
+ });
+ });
+});
diff --git a/src/events/events.service.ts b/src/events/events.service.ts
new file mode 100644
index 000000000..06dc5baaf
--- /dev/null
+++ b/src/events/events.service.ts
@@ -0,0 +1,278 @@
+import {
+ Injectable,
+ NotFoundException,
+ BadRequestException,
+} from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { In, Repository } from 'typeorm';
+import { Event } from './event.entity';
+import { EventStatus } from './event-status.enum';
+import { CreateEventDto } from './dto/create-event.dto';
+import { User } from '../users/user.entity';
+
+@Injectable()
+export class EventsService {
+ constructor(
+ @InjectRepository(Event)
+ private readonly eventsRepository: Repository,
+ @InjectRepository(User)
+ private readonly usersRepository: Repository,
+ ) {}
+
+ /** Create a new event and update the invitees' `events` lists. */
+ async create(createEventDto: CreateEventDto): Promise {
+ this.assertTimeRange(createEventDto.startTime, createEventDto.endTime);
+
+ const inviteeIds = createEventDto.invitees ?? [];
+ let invitees: User[] = [];
+ if (inviteeIds.length > 0) {
+ invitees = await this.usersRepository.find({
+ where: { id: In(inviteeIds) },
+ });
+ if (invitees.length !== inviteeIds.length) {
+ const found = new Set(invitees.map((u) => u.id));
+ const missing = inviteeIds.filter((id) => !found.has(id));
+ throw new NotFoundException(
+ `Invitee user(s) not found: ${missing.join(', ')}`,
+ );
+ }
+ }
+
+ const event = this.eventsRepository.create({
+ title: createEventDto.title,
+ description: createEventDto.description ?? null,
+ status: createEventDto.status ?? EventStatus.TODO,
+ startTime: createEventDto.startTime,
+ endTime: createEventDto.endTime,
+ invitees,
+ });
+
+ const saved = await this.eventsRepository.save(event);
+
+ // Mirror relation onto User.events (string[] of event IDs) per spec.
+ if (invitees.length > 0) {
+ await this.appendEventToUsers(invitees, saved.id);
+ }
+
+ return this.findById(saved.id);
+ }
+
+ async findById(id: string): Promise {
+ if (!id) {
+ throw new BadRequestException('Event id is required');
+ }
+ const event = await this.eventsRepository.findOne({
+ where: { id },
+ relations: { invitees: true },
+ });
+ if (!event) {
+ throw new NotFoundException(`Event with id "${id}" not found`);
+ }
+ return event;
+ }
+
+ async findAll(): Promise {
+ return this.eventsRepository.find({
+ relations: { invitees: true },
+ order: { startTime: 'ASC' },
+ });
+ }
+
+ /**
+ * Return only the events that a specific user is invited to.
+ * Used by GET /events?userId=:id.
+ */
+ async findAllForUser(userId: string): Promise {
+ const user = await this.usersRepository.findOne({
+ where: { id: userId },
+ });
+ if (!user) {
+ throw new NotFoundException(`User with id "${userId}" not found`);
+ }
+ const eventIds = (user.events ?? []).filter(Boolean);
+ if (eventIds.length === 0) {
+ return [];
+ }
+ return this.eventsRepository.find({
+ where: { id: In(eventIds) },
+ relations: { invitees: true },
+ order: { startTime: 'ASC' },
+ });
+ }
+
+ async deleteById(id: string): Promise {
+ const event = await this.eventsRepository.findOne({
+ where: { id },
+ relations: { invitees: true },
+ });
+ if (!event) {
+ throw new NotFoundException(`Event with id "${id}" not found`);
+ }
+
+ const invitees = event.invitees ?? [];
+
+ // Clear join-table rows before deleting the parent row, otherwise the
+ // FOREIGN KEY on event_invitees.event_id blocks the DELETE.
+ if (invitees.length > 0) {
+ event.invitees = [];
+ await this.eventsRepository.save(event);
+ }
+
+ // Pull the event id out of every invitee's `events` list.
+ for (const user of invitees) {
+ user.events = (user.events ?? []).filter((eid) => eid !== id);
+ await this.usersRepository.save(user);
+ }
+ await this.eventsRepository.remove(event);
+ }
+
+ /**
+ * MergeAll: for the given user, collapse every overlapping pair of
+ * events the user is invited to into a single superset event.
+ * Per the assignment FAQ:
+ * - Mutates the database (not just a response)
+ * - Appends titles/descriptions and picks a reasonable status
+ * - Union of invitees is preserved
+ */
+ async mergeAllForUser(userId: string): Promise {
+ const user = await this.usersRepository.findOne({
+ where: { id: userId },
+ });
+ if (!user) {
+ throw new NotFoundException(`User with id "${userId}" not found`);
+ }
+
+ const eventIds = (user.events ?? []).filter(Boolean);
+ if (eventIds.length === 0) {
+ return [];
+ }
+
+ const userEvents = await this.eventsRepository.find({
+ where: { id: In(eventIds) },
+ relations: { invitees: true },
+ });
+
+ if (userEvents.length === 0) {
+ return [];
+ }
+
+ // Sort by startTime so we can sweep and merge.
+ const sorted = [...userEvents].sort(
+ (a, b) => a.startTime.getTime() - b.startTime.getTime(),
+ );
+
+ type Cluster = {
+ startTime: Date;
+ endTime: Date;
+ titles: string[];
+ descriptions: string[];
+ statuses: EventStatus[];
+ members: Event[];
+ };
+
+ const clusters: Cluster[] = [];
+ for (const ev of sorted) {
+ const last = clusters[clusters.length - 1];
+ if (last && ev.startTime.getTime() <= last.endTime.getTime()) {
+ last.endTime =
+ ev.endTime.getTime() > last.endTime.getTime()
+ ? ev.endTime
+ : last.endTime;
+ if (!last.titles.includes(ev.title)) last.titles.push(ev.title);
+ if (ev.description && !last.descriptions.includes(ev.description)) {
+ last.descriptions.push(ev.description);
+ }
+ if (!last.statuses.includes(ev.status)) last.statuses.push(ev.status);
+ last.members.push(ev);
+ } else {
+ clusters.push({
+ startTime: ev.startTime,
+ endTime: ev.endTime,
+ titles: [ev.title],
+ descriptions: ev.description ? [ev.description] : [],
+ statuses: [ev.status],
+ members: [ev],
+ });
+ }
+ }
+
+ const merged: Event[] = [];
+
+ for (const cluster of clusters) {
+ if (cluster.members.length === 1) {
+ merged.push(cluster.members[0]);
+ continue;
+ }
+
+ const inviteeMap = new Map();
+ for (const m of cluster.members) {
+ for (const u of m.invitees ?? []) {
+ inviteeMap.set(u.id, u);
+ }
+ }
+ const mergedInvitees = Array.from(inviteeMap.values());
+
+ const newStatus = pickStatus(cluster.statuses);
+
+ const newEvent = this.eventsRepository.create({
+ title: cluster.titles.join(' | '),
+ description:
+ cluster.descriptions.length > 0
+ ? cluster.descriptions.join(' | ')
+ : null,
+ status: newStatus,
+ startTime: cluster.startTime,
+ endTime: cluster.endTime,
+ invitees: mergedInvitees,
+ });
+
+ const saved = await this.eventsRepository.save(newEvent);
+ const oldIds = new Set(cluster.members.map((m) => m.id));
+
+ for (const m of cluster.members) {
+ m.invitees = [];
+ await this.eventsRepository.save(m);
+ }
+ await this.eventsRepository.delete(Array.from(oldIds));
+
+ for (const u of mergedInvitees) {
+ const next = (u.events ?? []).filter((eid) => !oldIds.has(eid));
+ if (!next.includes(saved.id)) next.push(saved.id);
+ u.events = next;
+ await this.usersRepository.save(u);
+ }
+
+ merged.push(await this.findById(saved.id));
+ }
+
+ return merged;
+ }
+
+ // ── helpers ──────────────────────────────────────────────────────────────
+
+ private assertTimeRange(start: Date, end: Date): void {
+ if (end.getTime() <= start.getTime()) {
+ throw new BadRequestException('endTime must be after startTime');
+ }
+ }
+
+ private async appendEventToUsers(
+ users: User[],
+ eventId: string,
+ ): Promise {
+ for (const user of users) {
+ const current = user.events ?? [];
+ if (!current.includes(eventId)) {
+ user.events = [...current, eventId];
+ await this.usersRepository.save(user);
+ }
+ }
+ }
+}
+
+function pickStatus(statuses: EventStatus[]): EventStatus {
+ const set = new Set(statuses);
+ if (set.has(EventStatus.IN_PROGRESS)) return EventStatus.IN_PROGRESS;
+ if (set.has(EventStatus.COMPLETED)) return EventStatus.COMPLETED;
+ return EventStatus.TODO;
+}
diff --git a/src/main.ts b/src/main.ts
index 13cad38cf..0806d376b 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,8 +1,17 @@
import { NestFactory } from '@nestjs/core';
+import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
- await app.listen(3000);
+ app.useGlobalPipes(
+ new ValidationPipe({
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ transform: true,
+ transformOptions: { enableImplicitConversion: true },
+ }),
+ );
+ await app.listen(process.env.PORT ?? 3000);
}
-bootstrap();
+void bootstrap();
diff --git a/src/users/dto/create-user.dto.ts b/src/users/dto/create-user.dto.ts
new file mode 100644
index 000000000..fdf44e344
--- /dev/null
+++ b/src/users/dto/create-user.dto.ts
@@ -0,0 +1,8 @@
+import { IsString, MaxLength, MinLength } from 'class-validator';
+
+export class CreateUserDto {
+ @IsString()
+ @MinLength(1)
+ @MaxLength(255)
+ name!: string;
+}
diff --git a/src/users/user.entity.ts b/src/users/user.entity.ts
new file mode 100644
index 000000000..2537b305d
--- /dev/null
+++ b/src/users/user.entity.ts
@@ -0,0 +1,38 @@
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ JoinTable,
+ ManyToMany,
+ PrimaryGeneratedColumn,
+ UpdateDateColumn,
+} from 'typeorm';
+import { Event } from '../events/event.entity';
+
+@Entity({ name: 'users' })
+export class User {
+ @PrimaryGeneratedColumn('uuid')
+ id!: string;
+
+ @Column({ type: 'varchar', length: 255 })
+ name!: string;
+
+ // Stored as an array of event IDs per the assignment spec.
+ // Kept in sync with the @ManyToMany relation via the service layer.
+ @Column({ type: 'simple-array', default: '' })
+ events!: string[];
+
+ @CreateDateColumn()
+ createdAt!: Date;
+
+ @UpdateDateColumn()
+ updatedAt!: Date;
+
+ @ManyToMany(() => Event, (event) => event.invitees)
+ @JoinTable({
+ name: 'event_invitees',
+ joinColumn: { name: 'user_id', referencedColumnName: 'id' },
+ inverseJoinColumn: { name: 'event_id', referencedColumnName: 'id' },
+ })
+ invitedEvents?: Event[];
+}
diff --git a/src/users/users.controller.spec.ts b/src/users/users.controller.spec.ts
new file mode 100644
index 000000000..c61c37448
--- /dev/null
+++ b/src/users/users.controller.spec.ts
@@ -0,0 +1,58 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { getRepositoryToken } from '@nestjs/typeorm';
+import { UsersController } from './users.controller';
+import { UsersService } from './users.service';
+import { EventsService } from '../events/events.service';
+import { User } from './user.entity';
+import { Event } from '../events/event.entity';
+
+describe('UsersController', () => {
+ let controller: UsersController;
+ let usersService: UsersService;
+ let eventsService: EventsService;
+
+ const userRepoMock = {
+ create: jest.fn((d) => d),
+ save: jest.fn(async (u: User) => ({ id: 'u-1', ...u } as User)),
+ findOne: jest.fn(),
+ find: jest.fn(),
+ };
+ const eventRepoMock = { rows: new Map(), findOne: jest.fn() };
+
+ beforeEach(async () => {
+ const moduleRef: TestingModule = await Test.createTestingModule({
+ controllers: [UsersController],
+ providers: [
+ UsersService,
+ EventsService,
+ { provide: getRepositoryToken(User), useValue: userRepoMock },
+ { provide: getRepositoryToken(Event), useValue: eventRepoMock },
+ ],
+ }).compile();
+
+ controller = moduleRef.get(UsersController);
+ usersService = moduleRef.get(UsersService);
+ eventsService = moduleRef.get(EventsService);
+
+ jest
+ .spyOn(usersService, 'create')
+ .mockImplementation(async (dto) => ({ id: 'u-1', ...dto } as User));
+ jest
+ .spyOn(eventsService, 'mergeAllForUser')
+ .mockImplementation(async (id) => [
+ { id: 'merged-1', title: 'Merged' } as unknown as Event,
+ ]);
+ });
+
+ it('POST /users -> usersService.create()', async () => {
+ const result = await controller.create({ name: 'Ada' } as never);
+ expect(usersService.create).toHaveBeenCalled();
+ expect((result as User).id).toBe('u-1');
+ });
+
+ it('POST /users/:id/merge-events -> eventsService.mergeAllForUser()', async () => {
+ const result = await controller.mergeEvents('u-1');
+ expect(eventsService.mergeAllForUser).toHaveBeenCalledWith('u-1');
+ expect(result).toHaveLength(1);
+ });
+});
diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts
new file mode 100644
index 000000000..b966a453a
--- /dev/null
+++ b/src/users/users.controller.ts
@@ -0,0 +1,48 @@
+import {
+ Body,
+ Controller,
+ Get,
+ HttpCode,
+ HttpStatus,
+ Param,
+ ParseUUIDPipe,
+ Post,
+} from '@nestjs/common';
+import { UsersService } from './users.service';
+import { CreateUserDto } from './dto/create-user.dto';
+import { User } from './user.entity';
+import { EventsService } from '../events/events.service';
+import { Event } from '../events/event.entity';
+
+@Controller('users')
+export class UsersController {
+ constructor(
+ private readonly usersService: UsersService,
+ private readonly eventsService: EventsService,
+ ) {}
+
+ @Post()
+ @HttpCode(HttpStatus.CREATED)
+ create(@Body() createUserDto: CreateUserDto): Promise {
+ return this.usersService.create(createUserDto);
+ }
+
+ @Get()
+ findAll(): Promise {
+ return this.usersService.findAll();
+ }
+
+ @Get(':id')
+ findOne(@Param('id', new ParseUUIDPipe()) id: string): Promise {
+ return this.usersService.findById(id);
+ }
+
+ /** Merge all overlapping events for the user (per assignment spec). */
+ @Post(':id/merge-events')
+ @HttpCode(HttpStatus.OK)
+ mergeEvents(
+ @Param('id', new ParseUUIDPipe()) id: string,
+ ): Promise {
+ return this.eventsService.mergeAllForUser(id);
+ }
+}
diff --git a/src/users/users.module.ts b/src/users/users.module.ts
new file mode 100644
index 000000000..2ce920bbd
--- /dev/null
+++ b/src/users/users.module.ts
@@ -0,0 +1,14 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { UsersService } from './users.service';
+import { UsersController } from './users.controller';
+import { User } from './user.entity';
+import { EventsModule } from '../events/events.module';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([User]), EventsModule],
+ controllers: [UsersController],
+ providers: [UsersService],
+ exports: [UsersService, TypeOrmModule],
+})
+export class UsersModule {}
diff --git a/src/users/users.service.ts b/src/users/users.service.ts
new file mode 100644
index 000000000..3cf3a886d
--- /dev/null
+++ b/src/users/users.service.ts
@@ -0,0 +1,33 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { User } from './user.entity';
+import { CreateUserDto } from './dto/create-user.dto';
+
+@Injectable()
+export class UsersService {
+ constructor(
+ @InjectRepository(User)
+ private readonly usersRepository: Repository,
+ ) {}
+
+ async create(createUserDto: CreateUserDto): Promise {
+ const user = this.usersRepository.create({
+ name: createUserDto.name,
+ events: [],
+ });
+ return this.usersRepository.save(user);
+ }
+
+ async findAll(): Promise {
+ return this.usersRepository.find();
+ }
+
+ async findById(id: string): Promise {
+ const user = await this.usersRepository.findOne({ where: { id } });
+ if (!user) {
+ throw new NotFoundException(`User with id "${id}" not found`);
+ }
+ return user;
+ }
+}
diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts
deleted file mode 100644
index fea0c1834..000000000
--- a/test/app.e2e-spec.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { INestApplication } from '@nestjs/common';
-import { Test } from '@nestjs/testing';
-import request from 'supertest';
-import { App } from 'supertest/types';
-import { AppModule } from './../src/app.module';
-
-describe('AppController (e2e)', () => {
- let app: INestApplication;
-
- beforeAll(async () => {
- const moduleFixture = await Test.createTestingModule({
- imports: [AppModule],
- }).compile();
-
- app = moduleFixture.createNestApplication();
- await app.init();
- });
-
- afterAll(async () => {
- await app.close();
- });
-
- it('/ (GET)', () => {
- return request(app.getHttpServer())
- .get('/')
- .expect(200)
- .expect('Hello World!');
- });
-});
diff --git a/test/events.e2e-spec.ts b/test/events.e2e-spec.ts
new file mode 100644
index 000000000..459cfd435
--- /dev/null
+++ b/test/events.e2e-spec.ts
@@ -0,0 +1,224 @@
+import { INestApplication, ValidationPipe } from '@nestjs/common';
+import { Test, TestingModule } from '@nestjs/testing';
+import * as net from 'node:net';
+import request from 'supertest';
+import { App } from 'supertest/types';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { Event } from '../src/events/event.entity';
+import { User } from '../src/users/user.entity';
+import { EventsModule } from '../src/events/events.module';
+import { UsersModule } from '../src/users/users.module';
+
+/**
+ * End-to-end tests that boot the full Nest app against a real
+ * SQLite database file (per assignment FAQ #1: do "both" — mocked
+ * unit tests + a real DB path).
+ *
+ * Stability notes:
+ * - A free OS port is claimed before each suite so parallel jest
+ * workers never collide.
+ * - The temp DB file is deleted in afterAll.
+ */
+describe('Events REST API (e2e, real SQLite DB)', () => {
+ let app: INestApplication;
+ const testDbPath = `test/e2e-${Date.now()}-${Math.random()
+ .toString(36)
+ .slice(2)}.sqlite`;
+
+ function getFreePort(): Promise {
+ return new Promise((resolve, reject) => {
+ const probe = net.createServer();
+ probe.unref();
+ probe.on('error', reject);
+ probe.listen(0, '127.0.0.1', () => {
+ const addr = probe.address();
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
+ probe.close(() => resolve(port));
+ });
+ });
+ }
+
+ beforeAll(async () => {
+ const port = await getFreePort();
+
+ const moduleRef: TestingModule = await Test.createTestingModule({
+ imports: [
+ TypeOrmModule.forRoot({
+ type: 'better-sqlite3',
+ database: testDbPath,
+ entities: [Event, User],
+ synchronize: true,
+ }),
+ EventsModule,
+ UsersModule,
+ ],
+ }).compile();
+
+ app = moduleRef.createNestApplication();
+ app.useGlobalPipes(
+ new ValidationPipe({
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ transform: true,
+ transformOptions: { enableImplicitConversion: true },
+ }),
+ );
+ await app.init();
+ await app.listen(port, '127.0.0.1');
+ });
+
+ afterAll(async () => {
+ try {
+ await app.close();
+ } catch {
+ /* ignore */
+ }
+ await new Promise((r) => setTimeout(r, 100));
+ try {
+ const fs = await import('node:fs/promises');
+ await fs.unlink(testDbPath);
+ } catch {
+ /* ignore */
+ }
+ });
+
+ // ── Test 1: validation ───────────────────────────────────────────────────
+
+ it('rejects malformed event payloads (400)', async () => {
+ await request(app.getHttpServer())
+ .post('/events')
+ .send({ title: '' })
+ .expect(400);
+ });
+
+ // ── Test 2: full CRUD + merge via EventsController ───────────────────────
+
+ it('supports full CRUD + merge via POST /events/merge/:userId', async () => {
+ // Create two users.
+ const ada = await request(app.getHttpServer())
+ .post('/users')
+ .send({ name: 'Ada' })
+ .expect(201);
+ const bea = await request(app.getHttpServer())
+ .post('/users')
+ .send({ name: 'Bea' })
+ .expect(201);
+ const adaId = ada.body.id as string;
+ const beaId = bea.body.id as string;
+
+ // Create two overlapping events.
+ const e1 = await request(app.getHttpServer())
+ .post('/events')
+ .send({
+ title: 'Standup',
+ description: 'morning sync',
+ status: 'TODO',
+ startTime: '2026-01-01T10:00:00Z',
+ endTime: '2026-01-01T11:00:00Z',
+ invitees: [adaId],
+ })
+ .expect(201);
+ const e1Id = e1.body.id as string;
+
+ const e2 = await request(app.getHttpServer())
+ .post('/events')
+ .send({
+ title: '1:1',
+ description: 'pairing',
+ status: 'IN_PROGRESS',
+ startTime: '2026-01-01T10:45:00Z',
+ endTime: '2026-01-01T12:00:00Z',
+ invitees: [adaId, beaId],
+ })
+ .expect(201);
+ const e2Id = e2.body.id as string;
+
+ // GET /events/:id
+ const fetched = await request(app.getHttpServer())
+ .get(`/events/${e1Id}`)
+ .expect(200);
+ expect(fetched.body.title).toBe('Standup');
+
+ // GET /events?userId=adaId — should return both of Ada's events.
+ const adaEvents = await request(app.getHttpServer())
+ .get(`/events?userId=${adaId}`)
+ .expect(200);
+ expect(adaEvents.body.map((e: { id: string }) => e.id)).toEqual(
+ expect.arrayContaining([e1Id, e2Id]),
+ );
+
+ // POST /events/merge/:userId — EventsController route.
+ const merged = await request(app.getHttpServer())
+ .post(`/events/merge/${adaId}`)
+ .expect(200);
+ expect(merged.body).toHaveLength(1);
+ const m = merged.body[0];
+ expect(m.startTime).toBe('2026-01-01T10:00:00.000Z');
+ expect(m.endTime).toBe('2026-01-01T12:00:00.000Z');
+ expect(m.title).toContain('Standup');
+ expect(m.title).toContain('1:1');
+ expect(m.status).toBe('IN_PROGRESS'); // IN_PROGRESS beats TODO
+
+ // Originals are gone.
+ await request(app.getHttpServer()).get(`/events/${e1Id}`).expect(404);
+ await request(app.getHttpServer()).get(`/events/${e2Id}`).expect(404);
+
+ // Ada's events list now points only at the merged event.
+ const adaAfter = await request(app.getHttpServer())
+ .get(`/users/${adaId}`)
+ .expect(200);
+ expect(adaAfter.body.events).toEqual([m.id]);
+
+ // DELETE /events/:id
+ await request(app.getHttpServer()).delete(`/events/${m.id}`).expect(204);
+ await request(app.getHttpServer()).get(`/events/${m.id}`).expect(404);
+ });
+
+ // ── Test 3: merge via UsersController ────────────────────────────────────
+
+ it('POST /users/:id/merge-events also merges correctly (UsersController route)', async () => {
+ const cy = await request(app.getHttpServer())
+ .post('/users')
+ .send({ name: 'Cy' })
+ .expect(201);
+ const cyId = cy.body.id as string;
+
+ await request(app.getHttpServer())
+ .post('/events')
+ .send({
+ title: 'Alpha',
+ status: 'TODO',
+ startTime: '2026-03-01T09:00:00Z',
+ endTime: '2026-03-01T10:00:00Z',
+ invitees: [cyId],
+ })
+ .expect(201);
+
+ await request(app.getHttpServer())
+ .post('/events')
+ .send({
+ title: 'Beta',
+ status: 'COMPLETED',
+ startTime: '2026-03-01T09:30:00Z',
+ endTime: '2026-03-01T11:00:00Z',
+ invitees: [cyId],
+ })
+ .expect(201);
+
+ const merged = await request(app.getHttpServer())
+ .post(`/users/${cyId}/merge-events`)
+ .expect(200);
+ expect(merged.body).toHaveLength(1);
+ expect(merged.body[0].title).toContain('Alpha');
+ expect(merged.body[0].title).toContain('Beta');
+ expect(merged.body[0].status).toBe('COMPLETED'); // COMPLETED wins when no IN_PROGRESS
+ });
+
+ // ── Test 4: 404 for unknown event ────────────────────────────────────────
+
+ it('returns 404 for an unknown event id', async () => {
+ await request(app.getHttpServer())
+ .get('/events/00000000-0000-4000-8000-000000000000')
+ .expect(404);
+ });
+});