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 @@ -

- Nest Logo -

- -[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.

-

-NPM Version -Package License -NPM Downloads -CircleCI -Discord -Backers on Open Collective -Sponsors on Open Collective - Donate us - Support us - Follow us on Twitter -

- - -## 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); + }); +});