diff --git a/.claude/skills/CONVENTIONS.md b/.claude/skills/CONVENTIONS.md new file mode 100644 index 000000000..3e57a19fe --- /dev/null +++ b/.claude/skills/CONVENTIONS.md @@ -0,0 +1,44 @@ +# Shared Conventions + +Rules that apply across all skills. Each SKILL.md references this file instead of repeating these. + +--- + +## Scope + +- **Stay inside the target package directory.** Do not read files outside of it unless explicitly told to. +- **Do not invent features.** Only document or test what source code confirms exists. + +## Code Examples + +- Use **TypeScript** in all code examples. +- Keep examples minimal — just enough to show the point, no boilerplate. + +## Testing + +- **Use real Fastify instances. Do NOT mock Fastify.** Plugins are side-effect functions — mocking the instance means testing nothing. +- **Do NOT mock base-library plugins** (e.g., `@fastify/swagger`, `@fastify/multipart`). The point of the integration layer tests is to catch breakage from dependency upgrades. Mock only our own modules (migrations, sub-plugins we authored). +- **Always close Fastify instances in `afterEach`** to avoid resource leaks. +- **Use Vitest** (`import from "vitest"`). The project already has it configured. +- **Test what WE wrote, not what third-party libraries do.** Ask: "Does this verify code our team wrote, or that a third-party library works?" +- **Name tests by behavior**, not implementation. GOOD: `"decorates instance with default documentation path"` BAD: `"line 23 sets routePrefix"` + +### Known Fastify 5 Gotchas + +These patterns have been validated in this monorepo. Follow them to avoid known pitfalls: + +1. **`hasContentTypeParser("*")` returns false** even when a `*` catch-all parser is registered in Fastify 5. Use a behavioural test instead: inject a request with an unusual content-type and assert the status is not 415. +2. **Asserting `vi.fn()` plugin calls**: always include `expect.any(Function)` as the third argument — Fastify calls plugins as `plugin(fastify, options, done)`. +3. **`Readable.from(["string"])` emits strings, not Buffers.** `Buffer.concat` will throw. Use `Readable.from([Buffer.from("string")])` instead. +4. **Verify `@fastify/multipart`** with `fastify.hasContentTypeParser("multipart/form-data")`, not `getSchema("fileSchema")` — `sharedSchemaId` does not expose a schema via `fastify.getSchema`. + +## Base Library Documentation + +- **Do not repeat base library documentation in detail.** Link to their docs. +- **For doc links:** use the library's official docs URL. If unsure, use the npm page: `https://www.npmjs.com/package/{package-name}`. +- **List only the delta** for partial/modified passthroughs — what we change, not what we preserve. + +## Reference Packages + +- `packages/firebase` — has FEATURES.md and comprehensive tests +- `packages/config` — has GUIDE.md as the format reference diff --git a/.claude/skills/README.md b/.claude/skills/README.md new file mode 100644 index 000000000..9c906e746 --- /dev/null +++ b/.claude/skills/README.md @@ -0,0 +1,82 @@ +# Skills + +Slash commands for analyzing, documenting, and testing Fastify plugin packages. + +All skills share conventions defined in [CONVENTIONS.md](CONVENTIONS.md) — testing rules, code example standards, Fastify 5 gotchas, and base library documentation patterns. + +## Dependency Graph + +Skills automatically invoke their prerequisites. You do not need to chain them manually. + +``` +/analyze-package (no dependencies — reads source code) + ↓ +/write-docs (runs /analyze-package if needed) + ↓ +/write-developer-docs (runs /analyze-package if needed) +/write-tests (runs /write-docs if FEATURES.md missing) +``` + +Orchestrator skills run the full chain: + +``` +/package-developer-docs → /analyze-package → /write-developer-docs +/package-full → /analyze-package → /write-docs → /write-developer-docs → /write-tests +``` + +## Available Skills + +| Skill | Description | Produces | +| -------------------------------- | --------------------------------------------------------- | --------------------------------------- | +| `/analyze-package ` | Explore source code and classify it as "ours" vs "theirs" | `ANALYSIS.md`, analysis in conversation | +| `/write-docs ` | Create/update FEATURES.md and GUIDE.md | `FEATURES.md`, `GUIDE.md` | +| `/write-developer-docs ` | Create/update README.md for developer evaluation | `README.md` | +| `/write-tests ` | Write and run unit tests | `src/__test__/*.test.ts` | +| `/package-developer-docs ` | Orchestrator: analyze → developer-docs | Analysis + `README.md` | +| `/package-full ` | Orchestrator: analyze → docs → developer-docs → tests | All docs + tests | + +## Usage + +### Full pipeline (new package) + +``` +/package-full packages/my-plugin +``` + +Runs analyze → docs → developer-docs → tests in order. Use this when starting from scratch. + +### Individual skills (self-sufficient) + +Each skill auto-invokes its prerequisites if needed. Run any skill standalone: + +``` +/write-docs packages/my-plugin # auto-runs /analyze-package first +/write-developer-docs packages/my-plugin # auto-runs /analyze-package first +/write-tests packages/my-plugin # auto-runs /write-docs if FEATURES.md missing +``` + +### Reusing analysis in one conversation + +If you run `/analyze-package` first, downstream skills detect the existing analysis and skip re-running it: + +``` +/analyze-package packages/my-plugin # explore and classify code +/write-docs packages/my-plugin # reuses analysis above (no re-run) +/write-developer-docs packages/my-plugin # reuses analysis above (no re-run) +``` + +### Common scenarios + +| Scenario | What to run | +| ---------------------------------- | ---------------------------------- | +| New package, need everything | `/package-full` | +| Just need README for evaluation | `/package-developer-docs` | +| Docs exist, just need tests | `/write-tests` | +| Source changed, update docs only | `/write-docs` | +| Want to understand a package first | `/analyze-package` | +| Tests broke after dependency bump | `/write-tests` (fix/rewrite tests) | + +## Reference packages + +- `packages/firebase` — has FEATURES.md and comprehensive tests +- `packages/config` — has GUIDE.md as the format reference diff --git a/.claude/skills/analyze-package/SKILL.md b/.claude/skills/analyze-package/SKILL.md new file mode 100644 index 000000000..30016ac14 --- /dev/null +++ b/.claude/skills/analyze-package/SKILL.md @@ -0,0 +1,110 @@ +--- +name: analyze-package +description: Explore a Fastify plugin package and produce a structured analysis classifying code as "ours" vs "theirs" with passthrough behavior. Use this skill whenever the user asks to analyze, understand, break down, or explore a package — e.g., "what does packages/auth do?", "break down this plugin", "analyze packages/config". Also triggers as a prerequisite for /write-docs, /write-developer-docs, and /write-tests. +argument-hint: [package-path] +effort: low +--- + +# Analyze a Fastify Plugin Package + +Analyze the package at: `$ARGUMENTS` + +If no path is provided, use the current working directory. + +Read `.claude/skills/CONVENTIONS.md` now before starting any analysis. + +--- + +## Step 1: Read the Package + +Read `src/plugin.ts` and `src/index.ts` first — these are the entry points. Then follow their imports to read only the files they reference. Do not read every file blindly; large packages will exhaust context. + +Also read these files if they exist: + +- `package.json` +- `README.md` +- `FEATURES.md` +- `GUIDE.md` +- Any existing tests in `src/__test__/` + +From these files, identify: dependencies, exports, types, decorators, hooks, plugins, defaults, and conditional logic. + +--- + +## Step 2: Classify Code — "Ours" vs "Theirs" + +Go through each function and code block. Classify: + +- **"Ours"**: Logic we wrote — conditionals, defaults, decorators, transformations, validation, option merging, utility functions, hooks, error handling, logging +- **"Theirs"**: Direct calls to third-party libraries with no transformation (e.g., just passing options through to `@fastify/swagger`) + +**Example of a good classification:** + +``` +// OURS — conditional decorator with default value +if (opts.enableMetrics !== false) { + fastify.decorate("metrics", { enabled: true, prefix: opts.prefix ?? "/metrics" }); +} + +// THEIRS — direct passthrough, no transformation +await fastify.register(fastifySwagger, opts.swagger); +``` + +--- + +## Step 3: Passthrough Analysis + +For each dependency the package wraps, answer: + +1. **Are all config options passed through?** Check if types come from the base library or if we define a subset. +2. **Do we transform, filter, or override any options?** Look for modifications before passing to the base library. +3. **Do we restrict any base library features?** +4. **What do we add on top?** List every feature our code adds. + +### Output format + +Print a section titled `## Base Library Passthrough Analysis` with a subsection per dependency: + +``` +### @scope/library-name — [FULL PASSTHROUGH | PARTIAL PASSTHROUGH | MODIFIED] + +- Options type: [imported from base library | custom subset] +- Options passed: [unmodified | transformed — describe how] +- Features restricted: [none | list them] +- Features added: [list them] +``` + +--- + +## Step 4: Summary + +Print a structured summary listing: + +- Every function/export with a one-line description +- Every decorator added +- Every hook registered +- Every conditional branch (feature flags, enable/disable logic) +- Default values + +### Completeness checklist + +Your analysis is complete when you have: + +- [ ] Classified every public export as "ours" or "theirs" +- [ ] Listed every Fastify decorator added +- [ ] Listed every Fastify hook registered +- [ ] Identified every conditional branch (enable/disable flags, feature toggles) +- [ ] Documented default values for all options we define +- [ ] Produced a passthrough classification for every wrapped dependency + +If any item is missing, go back and fill it in before finishing. + +### Save as ANALYSIS.md + +Once the checklist is complete, write the full analysis to `ANALYSIS.md` in the package root. This file is the persistent handoff to downstream skills — it means `/write-docs`, `/write-developer-docs`, and `/write-tests` can read your analysis from disk instead of re-running it. + +The file content is the same as what you printed: passthrough analysis + summary. Add this comment as the first line: + +``` + +``` diff --git a/.claude/skills/evals/evals.json b/.claude/skills/evals/evals.json new file mode 100644 index 000000000..40051b27b --- /dev/null +++ b/.claude/skills/evals/evals.json @@ -0,0 +1,53 @@ +{ + "skill_name": "fastify-plugin-skills", + "evals": [ + { + "id": 1, + "prompt": "analyze packages/config — I want to understand what this plugin does and what's ours vs what comes from base libraries", + "expected_output": "Structured analysis with ours/theirs classification, passthrough analysis per dependency, decorator list, hook list, conditional branches, and default values. Should complete the checklist.", + "files": [], + "expectations": [ + "Output contains a '## Base Library Passthrough Analysis' section", + "Classifies at least one export as 'ours' (our code)", + "Produces a passthrough classification (FULL PASSTHROUGH, PARTIAL PASSTHROUGH, or MODIFIED) for each wrapped dependency", + "Lists at least one Fastify decorator added by the plugin", + "Documents conditional branches or default values", + "Does NOT claim to read files outside the packages/config directory", + "Completeness checklist items are addressed (decorators, hooks, conditional branches, defaults)" + ] + }, + { + "id": 2, + "prompt": "document packages/config — create FEATURES.md and GUIDE.md for the config plugin", + "expected_output": "FEATURES.md with numbered features grouped by category, and GUIDE.md with setup, base library section, features with examples, and use cases. Should auto-run /analyze-package first.", + "files": [], + "expectations": [ + "FEATURES.md is created", + "GUIDE.md is created", + "FEATURES.md starts with the required HTML comment about structured feature inventory", + "FEATURES.md features are numbered sequentially (1., 2., 3. or ## 1.)", + "FEATURES.md does NOT list base library features — only features our code adds", + "GUIDE.md contains a Setup section showing plugin registration", + "GUIDE.md contains at least 3 TypeScript code examples", + "GUIDE.md does NOT contain empty sections or placeholder N/A text", + "Analysis was performed before writing (evidence of ours/theirs classification in the approach)" + ] + }, + { + "id": 3, + "prompt": "write tests for packages/config — I need unit tests covering the plugin behavior", + "expected_output": "Test files in src/__test__/ using Vitest with real Fastify instances. Tests should pass.", + "files": [], + "expectations": [ + "At least one test file created in src/__test__/", + "Tests import Fastify and create real instances (no vi.mock of Fastify)", + "Every test group has afterEach closing the Fastify instance", + "Tests cover conditional logic branches (if/else, enable/disable flags)", + "Tests verify at least one decorator exists on the Fastify instance", + "Tests do NOT mock the base library plugins (env-schema or similar)", + "All tests pass — test_results.txt shows no failures", + "Number of tests is between 5 and 20 (appropriate scope)" + ] + } + ] +} diff --git a/.claude/skills/package-developer-docs/SKILL.md b/.claude/skills/package-developer-docs/SKILL.md new file mode 100644 index 000000000..c54e0f317 --- /dev/null +++ b/.claude/skills/package-developer-docs/SKILL.md @@ -0,0 +1,37 @@ +--- +name: package-developer-docs +description: Run analysis then generate developer-facing README.md for a Fastify plugin package. Use this skill when the user wants a README with analysis — e.g., "create a README for packages/auth", "I need developer docs for this plugin". +argument-hint: [package-path] +effort: medium +--- + +# Package Developer Docs Pipeline + +Generate developer-facing documentation for: `$ARGUMENTS` + +If no path is provided, use the current working directory. + +This skill runs two steps in sequence. Complete each step fully before starting the next. + +--- + +## Step 1: Analyze the Package + +Run `/analyze-package $ARGUMENTS` and wait for it to complete. + +--- + +## Step 2: Write README.md + +Run `/write-developer-docs $ARGUMENTS` and wait for it to complete. It will use the analysis from Step 1. + +--- + +## Output Summary + +When both steps are complete, print: + +- Passthrough classifications per dependency +- Number of base library sections written +- Number of high-level features listed +- Whether FEATURES.md and GUIDE.md exist (for linking) diff --git a/.claude/skills/package-full/SKILL.md b/.claude/skills/package-full/SKILL.md new file mode 100644 index 000000000..3585d015a --- /dev/null +++ b/.claude/skills/package-full/SKILL.md @@ -0,0 +1,65 @@ +--- +name: package-full +description: Run the full analysis, documentation, and test-writing pipeline for a Fastify plugin package. Use this skill when the user wants everything done for a package — analysis, FEATURES.md, GUIDE.md, README.md, and tests — e.g., "set up packages/auth from scratch", "full pipeline for this plugin", "document and test packages/config". +argument-hint: [package-path] +effort: high +--- + +# Full Package Pipeline + +Run the complete pipeline for: `$ARGUMENTS` + +If no path is provided, use the current working directory. + +Follow the shared conventions in `.claude/skills/CONVENTIONS.md`. Read that file before starting. + +This skill runs four phases in sequence. Complete each phase fully before starting the next. All phases operate on the same package directory. + +--- + +## Phase 1: Analyze the Package + +Run `/analyze-package $ARGUMENTS` and wait for it to complete. + +This produces the structured analysis (ours vs theirs classification, passthrough analysis, summary) that all subsequent phases depend on. + +--- + +## Phase 2: Write FEATURES.md and GUIDE.md + +Run `/write-docs $ARGUMENTS` and wait for it to complete. + +This produces FEATURES.md (structured feature inventory for test generation) and GUIDE.md (comprehensive developer guide). + +--- + +## Phase 3: Write README.md + +Run `/write-developer-docs $ARGUMENTS` and wait for it to complete. + +This produces the developer-facing README.md with passthrough classifications, key features, and usage guidelines. + +--- + +## Phase 4: Write and Run Tests + +Run `/write-tests $ARGUMENTS` and wait for it to complete. + +This uses FEATURES.md from Phase 2 to determine what to test, writes the tests, runs them, and fixes any failures. + +--- + +## Output Summary + +When all four phases are complete, print a combined summary: + +- Number of features documented in FEATURES.md +- Number of features covered in GUIDE.md +- Number of use cases in GUIDE.md +- Passthrough classifications per dependency +- Number of base library sections in README.md +- Number of high-level features in README.md +- Whether Usage Guidelines section was written +- Number of tests written +- Test results (pass/fail) +- Any concerns found diff --git a/.claude/skills/write-developer-docs/SKILL.md b/.claude/skills/write-developer-docs/SKILL.md new file mode 100644 index 000000000..845f83db5 --- /dev/null +++ b/.claude/skills/write-developer-docs/SKILL.md @@ -0,0 +1,176 @@ +--- +name: write-developer-docs +description: Create or update README.md for a Fastify plugin package, aimed at developers evaluating whether to use the package. Use this skill when the user asks for a README, landing page, or developer-facing summary — e.g., "write a README for packages/auth", "create a README for this plugin", "I need a package summary for packages/config". +argument-hint: [package-path] +effort: medium +--- + +# Write Developer-Facing Documentation (README.md) + +Write the README for the package at: `$ARGUMENTS` + +If no path is provided, use the current working directory. + +Read `.claude/skills/CONVENTIONS.md` now before starting any work. + +--- + +## Prerequisites + +Check for analysis in this order: + +1. **`ANALYSIS.md` exists in the package** → read it now. Use it as the analysis for all subsequent steps. Do not re-run analysis. +2. **`/analyze-package` was already run in this conversation** → use that analysis from context. +3. **Neither** → run `/analyze-package $ARGUMENTS` now and wait for it to complete. It will write ANALYSIS.md for future use. + +--- + +## What This Skill Produces + +A `README.md` aimed at developers deciding whether to adopt this package. It answers: "What do I get, how should I use it, and where do I learn more?" + +There are three distinct layers of documentation: + +- **README.md** — landing page + opinionated design conventions (this file) +- **GUIDE.md** — comprehensive developer guide: passthrough analysis, every feature with examples, use cases +- **FEATURES.md** — structured feature inventory for automated test generation + +The README is richer than just a landing page. It also captures **design conventions** — the "you must / you should not" rules the package imposes on callers. These are NOT recipes (GUIDE.md covers those) and NOT features (FEATURES.md covers those). They are the package's opinionated contract with the caller, e.g. "controllers must throw instead of replying directly", "always subclass CustomError for domain errors". If the existing README has this kind of content, preserve it. If it doesn't, look for it in the analysis and add it. + +--- + +## Step 1: Classify Each Dependency + +Using the analysis (from `/analyze-package` or your own reading), classify each wrapped dependency into one of: + +| Classification | Meaning | README treatment | +| ----------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| **Full passthrough** | All config options forwarded unmodified. Types imported from base library. | Link to their docs. One sentence: "All options supported." | +| **Partial passthrough** | Most options forwarded, but we modify, default, or restrict some. | Link to their docs. List only what we change or restrict. | +| **Modified** | We significantly transform options, restrict features, or impose our own API surface. | Describe what's available and what's different from the base library. | + +If the package has no wrapped dependencies (pure custom code), skip the dependency sections entirely. + +--- + +## Step 2: Identify Key Features + +From the analysis, pick the **high-level features** our code adds. These are not the exhaustive FEATURES.md list — group and summarize: + +- Collapse related items (e.g., 5 invitation routes -> "Full invitation lifecycle — create, accept, revoke, resend, delete") +- Aim for 5-15 bullet points that a scanning developer can absorb in 30 seconds +- Focus on capabilities, not implementation details + +--- + +## Step 3: Read Existing Files + +Read these files if they exist in the package (skip any already read): + +- `README.md` — **read carefully before writing.** Preserve sections that are still accurate — especially "Why this plugin?", "Usage Guidelines", and any hand-written design conventions. Only update sections where the source code has changed. +- `package.json` — for the package name, description, dependencies, peer dependencies, and repository URL +- `FEATURES.md` and `GUIDE.md` — to link to them + +--- + +## Step 4: Write README.md + +Use this structure. Omit sections that don't apply (e.g., no "Base Libraries" section for pure custom packages, no "GraphQL" section if the package has no GraphQL support). + +```markdown +# {package-name} + +{One-line description from package.json, or write a better one if it's generic.} + +## Why This Plugin? + +{Keep existing "Why this plugin?" content if it exists and is good. Otherwise write 2-4 sentences explaining the problem this solves and why a developer would use it instead of wiring things up themselves.} + +## What You Get + +### {Base Library 1} — {Full Passthrough | Partial Passthrough | Modified} + +{For FULL: one sentence + link} +All configuration options from [{library name}]({docs-url}) are supported and passed through unchanged. + +{For PARTIAL: one sentence + link + delta list} +Most configuration options from [{library name}]({docs-url}) are supported. This plugin modifies: + +- **{option}** — {what we do differently} +- **{option}** — {what we restrict or default} + +{For MODIFIED: describe what's available} +Wraps [{library name}]({docs-url}) with a different configuration surface: + +- {what's different} + +### {Base Library 2} — ... + +{repeat per dependency} + +### Added by This Plugin + +- {High-level feature bullet} +- {High-level feature bullet} +- ... + +-> [Full feature list](FEATURES.md) | [Developer guide](GUIDE.md) + +## Usage Guidelines + +{Only include this section if the package imposes opinionated conventions on callers — rules they must follow for the package to work correctly or safely. These are NOT recipes. They are the contract.} + +{Examples of what belongs here:} + +- {A "do this / not that" rule with a brief wrong/correct code example} +- {A "you must always X" constraint with a one-line explanation of why} +- {A "never do Y" rule the package relies on} + +{If no such conventions exist, omit this section entirely.} + +## Requirements + +{List peer dependencies and sibling @prefabs.tech plugins that must be registered first. Link to their package directories.} + +## Quick Start + +{Minimal setup code — imports, config, registration. Keep it under 20 lines. Do not repeat what GUIDE.md covers in detail.} + +## Installation + +Install with npm: + +\`\`\`bash +npm install {package-name} +\`\`\` + +Install with pnpm: + +\`\`\`bash +pnpm add {package-name} +\`\`\` + +## GraphQL + +{Only if the package exports GraphQL schema/resolvers. Brief description + minimal wiring example. 1-2 code blocks max.} +``` + +### Rules + +1. **Preserve design conventions.** If the existing README has a "Usage Guidelines" section, "do this / not that" patterns, or "you must / never do" rules — keep them. These capture the package's opinionated contract with callers. Do not strip them on the grounds that GUIDE.md exists. They serve a different purpose. +2. **Do not repeat base library documentation.** Link to it. Developers know how to click. +3. **List only the delta** for partial/modified passthroughs — what we change, not what we preserve. +4. **Keep the feature list high-level.** FEATURES.md has the details. README sells the package. +5. **Use links to FEATURES.md and GUIDE.md** instead of duplicating recipes and feature lists. +6. **Code examples in the Usage Guidelines section are appropriate** — a short wrong/correct comparison is worth more than a paragraph. Keep them concise (under 10 lines each). +7. **Code examples in Quick Start should be minimal** — just enough to show the setup works. GUIDE.md has the full recipes. + +--- + +## Output Summary + +When done, print: + +- Number of base library sections written (with their classifications) +- Number of high-level features listed +- Whether FEATURES.md and GUIDE.md exist (for linking) diff --git a/.claude/skills/write-docs/SKILL.md b/.claude/skills/write-docs/SKILL.md new file mode 100644 index 000000000..85ffd0b18 --- /dev/null +++ b/.claude/skills/write-docs/SKILL.md @@ -0,0 +1,188 @@ +--- +name: write-docs +description: Create or update FEATURES.md and GUIDE.md for a Fastify plugin package based on source code analysis. Use this skill when the user asks to document a package, write docs, create a guide, or generate a feature list — e.g., "document packages/auth", "write docs for this plugin", "create a guide for packages/config". +argument-hint: [package-path] +effort: medium +--- + +# Write Documentation for a Fastify Plugin Package + +Document the package at: `$ARGUMENTS` + +If no path is provided, use the current working directory. + +Read `.claude/skills/CONVENTIONS.md` now before starting any work. + +--- + +## Prerequisites + +Check for analysis in this order: + +1. **`ANALYSIS.md` exists in the package** → read it now. Use it as the analysis for all subsequent steps. Do not re-run analysis. +2. **`/analyze-package` was already run in this conversation** → use that analysis from context. +3. **Neither** → run `/analyze-package $ARGUMENTS` now and wait for it to complete. It will write ANALYSIS.md for future use. + +--- + +## Part 1: Create or Update FEATURES.md + +Create or update `FEATURES.md` in the package root. + +**If FEATURES.md already exists:** Read it first. Preserve features that are still accurate. Only add, remove, or modify features where the source code has changed. Do not rewrite from scratch unless the existing file is fundamentally wrong. + +This file is a **structured feature inventory consumed by automated test generation** (`/write-tests`). It is NOT the developer-facing documentation — that's GUIDE.md. + +Add this comment as the first line of FEATURES.md: + +``` + +``` + +### Rules + +1. **Only list features our code adds.** Do not list features of base libraries — developers can read those libraries' official docs. +2. Number every feature sequentially. +3. Group features under category headings (`##`). +4. Add a code example to a feature ONLY if it needs one to show usage. Do not add examples to self-explanatory features. +5. Code examples should be minimal — just enough to show the feature, no boilerplate. + +### How to identify features + +A "feature" is any behavior our code provides. Extract from source code: + +- Plugin registration behavior (what it sets up) +- Configuration options we define (not passthrough options from base libraries) +- Fastify decorators we add +- Fastify hooks we register +- Default values we provide +- Conditional behaviors (enable/disable flags) +- Type exports and module augmentations +- Utility functions exposed to consumers +- Error handling and logging we add + +### Reference + +See `packages/firebase/FEATURES.md` for the expected format. + +--- + +## Part 2: Create or Update GUIDE.md + +Create or update `GUIDE.md` in the package root. + +**If GUIDE.md already exists:** Read it first. Preserve sections that are still accurate — especially hand-written examples and use cases. Only update sections where the source code has changed. Do not rewrite from scratch unless the existing file is fundamentally wrong. + +This is the **comprehensive developer guide** — the main documentation file for developers using this package. It covers installation, base library passthrough details, every feature with code examples, and practical use cases. + +This is different from: + +- **FEATURES.md** — structured inventory for test generation (not meant for humans to read end-to-end) +- **README.md** — landing page for scanning (produced by `/write-developer-docs`) + +### Structure + +**Important: Omit any section that does not apply to this package.** For example, skip "Base Libraries" entirely if the package wraps no dependencies. Do not write empty or "N/A" sections — just leave them out. + +Use the following template: + +```markdown +# {package-name} — Developer Guide + +## Installation + +### For package consumers + +\`\`\`bash +npm install {package-name} +\`\`\` + +\`\`\`bash +pnpm add {package-name} +\`\`\` + +### For monorepo development + +\`\`\`bash +pnpm install +pnpm --filter {package-name} test +pnpm --filter {package-name} build +\`\`\` + +## Setup + +{Show a complete, working setup once — imports, config object, plugin registration. State that all subsequent examples assume this setup. This eliminates boilerplate repetition in later sections.} + +--- + +## Base Libraries + +{One subsection per wrapped dependency. Skip this entire section if the package wraps nothing.} + +### {library} — {Full Passthrough | Partial Passthrough | Modified} + +{What this library provides, in one sentence.} + +-> **Their docs:** [{library}]({docs-url}) + +{For FULL: "All config options are passed through unchanged. See their docs for the full API."} + +{For PARTIAL: "Most options are passed through. We change:" + delta list of only what we modify/restrict/default.} + +{For MODIFIED: "We wrap this library with a different surface:" + describe what's available and different.} + +**What we add on top:** {list our additions related to this library} + +--- + +## Features + +{One subsection per feature or feature group. Every feature the package provides gets covered here with a description and code example. Group related features under a single heading when it improves readability.} + +### {Feature Name} + +{What it does, when you'd use it. 1-3 sentences.} + +\`\`\`typescript +// code example showing usage +\`\`\` + +--- + +## Use Cases + +{Practical scenarios showing how features combine to solve real problems. Each use case describes a situation a developer might face and shows the solution using this package.} + +### {Scenario title} + +{1-2 sentence setup: "When you need to X..." or "If your app requires Y..."} + +\`\`\`typescript +// code showing the complete solution +\`\`\` +``` + +### Rules + +1. **Cover every feature the package provides.** GUIDE.md must be comprehensive — a developer should not need to read source code to understand what's available. Check against FEATURES.md to ensure nothing is missed. +2. **Do not repeat base library documentation in detail.** Link to their docs. Only describe what we change, restrict, or add on top. +3. **Code examples are required** for every feature and every use case. Keep them minimal but complete enough to copy-paste. +4. **Do not repeat the full setup** in every example. Show setup once at the top, then reference it. +5. **Use cases should be realistic.** Think about what developers actually build with this package. Examples: "Handling third-party auth errors", "Multi-tenant configuration", "Development vs. production logging". + +### What NOT to include + +- Do not repeat the full config object in every example — show setup once +- Do not include deployment/environment-specific config examples +- Do not add explanations for things obvious from the code example + +--- + +## Output Summary + +When done, print: + +- Number of features documented in FEATURES.md +- Number of features covered in GUIDE.md +- Number of use cases in GUIDE.md +- Number of base library sections in GUIDE.md (with classifications) diff --git a/.claude/skills/write-tests/SKILL.md b/.claude/skills/write-tests/SKILL.md new file mode 100644 index 000000000..840d82b72 --- /dev/null +++ b/.claude/skills/write-tests/SKILL.md @@ -0,0 +1,158 @@ +--- +name: write-tests +description: Write unit tests for a Fastify plugin package. Uses FEATURES.md as the "what to test" guide and focuses on verifying custom code only. Use this skill when the user asks to write tests, add test coverage, or fix broken tests — e.g., "write tests for packages/auth", "test this plugin", "tests broke after dependency bump". +argument-hint: [package-path] +effort: medium +--- + +# Write Unit Tests for a Fastify Plugin Package + +Write tests for the package at: `$ARGUMENTS` + +If no path is provided, use the current working directory. + +Read `.claude/skills/CONVENTIONS.md` now before writing any tests — especially the **Testing** and **Known Fastify 5 Gotchas** sections. + +If you want to look at existing tests for reference, check the firebase package (`packages/firebase/src/__test__/`) or swagger package. Other package tests might not be standard. + +--- + +## Prerequisites + +Check for the required input files: + +**`FEATURES.md` must exist.** If it does not: + +> `FEATURES.md` not found in `$ARGUMENTS`. Run `/write-docs $ARGUMENTS` first to generate it, then re-run this skill. + +Stop. Do not proceed without it. + +**`ANALYSIS.md` is optional but useful.** If it exists, read it now — it gives you the full ours/theirs classification and passthrough analysis, which helps you identify what needs testing without re-reading all source files. + +--- + +## Step 1: Understand What to Test + +Read these files in the target package: + +- `FEATURES.md` — this is your primary guide for what to test +- All `.ts` files in `src/` — to understand the implementation +- `package.json` — to identify dependencies (base libraries we wrap) + +**How many tests to write:** Aim for one test per conditional branch (if/else, enable/disable flag) and one test per decorator. For option passthrough, one test per dependency is enough to verify wiring. This typically results in 5–20 tests per package — don't over-test. + +For each feature, decide if it needs a test: + +| Code pattern | Test? | Why | +| ----------------------------------- | ------------- | ------------------------------------------------ | +| Conditional logic (if/else) | YES | Every branch we wrote needs coverage | +| Default values we set | YES | Verify the defaults are what we documented | +| Decorators we add | YES | Verify they exist and have correct values | +| Options passthrough to base library | YES, one test | Verify the wiring works (not the library itself) | +| Type definitions | NO | Types are compile-time only | +| Direct re-exports | NO | Nothing to break | +| fastify-plugin wrapping | NO | That's their code | + +--- + +## Step 2: Write Tests + +### File location + +Create test files at `src/__test__/plugin.test.ts` (or appropriate names matching what is being tested). Multiple files are fine if it improves organization. + +If `src/__test__/` does not exist, create it. + +### Pattern for every test + +```typescript +import { describe, it, expect, afterEach } from "vitest"; +import Fastify, { type FastifyInstance } from "fastify"; +import plugin from "../plugin"; // adjust import path + +describe("plugin name", () => { + let fastify: FastifyInstance; + + afterEach(async () => { + await fastify.close(); + }); + + it("description of behavior", async () => { + fastify = Fastify(); + await fastify.register(plugin, { + /* options */ + }); + await fastify.ready(); + // assertions here + }); +}); +``` + +### Rules + +- **Use real Fastify instances. Do NOT mock Fastify.** +- **Do NOT mock base-library plugins.** Mock only our own modules. +- **Always close Fastify instances in `afterEach`.** +- **Name tests by behavior**, not implementation. + +### Assertions for common patterns + +**Decorator exists with value:** + +```typescript +expect(fastify.decoratorName).toBe("expected value"); +``` + +**Decorator does NOT exist (plugin disabled/skipped):** + +```typescript +expect(fastify.decoratorName).toBeUndefined(); +``` + +**Plugin wiring works (base library received our config):** +Use a lightweight integration check — call a method the base library provides and verify it reflects our config. + +**Route was registered:** + +```typescript +const response = await fastify.inject({ + method: "GET", + url: "/expected-route", +}); +expect(response.statusCode).toBe(200); +``` + +### What NOT to test + +- That base libraries generate correct output for various inputs +- TypeScript types at runtime +- `fastify-plugin` wrapping behavior +- Behavior with invalid inputs that TypeScript prevents +- Third-party library config options work — only test we pass them through +- Do not aim for 100% coverage — aim for testing every code path WE wrote + +--- + +## Step 3: Run and Fix + +1. Run `pnpm test` from the package directory to execute the tests +2. If tests fail, read the error carefully and fix the test or assertion +3. Do not change source code to make tests pass — tests verify existing behavior +4. All tests must pass before you are done + +### Common failure patterns + +- **"Cannot find module" or import errors** — check the import path. Use `../plugin` not `../src/plugin`. Check `package.json` exports if importing by package name. +- **Plugin registration throws** (e.g., missing env var, DB connection) — mock the specific setup function that needs external resources using `vi.mock()`. Do NOT mock Fastify or the base library plugin. +- **Timeout on `fastify.ready()`** — a plugin is waiting for something async that never resolves. Check if the plugin requires a database connection or external service and mock that dependency. +- **"already registered" errors** — create a fresh Fastify instance in each test, not shared across tests. The `afterEach` cleanup pattern in Step 2 handles this. + +--- + +## Output Summary + +When done, print: + +- Number of tests written +- Test results (pass/fail) +- Any concerns found (options silently dropped, missing passthrough, etc.) diff --git a/.commitlintrc.json b/.commitlintrc.json index 0df1d2536..c30e5a970 100644 --- a/.commitlintrc.json +++ b/.commitlintrc.json @@ -1,5 +1,3 @@ { - "extends": [ - "@commitlint/config-conventional" - ] + "extends": ["@commitlint/config-conventional"] } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 323d137d7..2b87a32c1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,7 +1,6 @@ name: "Test suite" -on: - push +on: push jobs: test: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e2f72b6b..c406ba237 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,57 +1,39 @@ ## [0.93.5](https://github.com/prefabs-tech/fastify/compare/v0.93.4...v0.93.5) (2026-02-11) - - ## [0.93.4](https://github.com/prefabs-tech/fastify/compare/v0.93.3...v0.93.4) (2026-01-15) - ### Bug Fixes -* **user:** fix error handling on create invitation handler ([#1048](https://github.com/prefabs-tech/fastify/issues/1048)) ([a915fca](https://github.com/prefabs-tech/fastify/commit/a915fcaafedce02a0df55fe265e9c9a610baeca5)) - - +- **user:** fix error handling on create invitation handler ([#1048](https://github.com/prefabs-tech/fastify/issues/1048)) ([a915fca](https://github.com/prefabs-tech/fastify/commit/a915fcaafedce02a0df55fe265e9c9a610baeca5)) ## [0.93.3](https://github.com/prefabs-tech/fastify/compare/v0.93.2...v0.93.3) (2026-01-06) - - ## [0.93.2](https://github.com/prefabs-tech/fastify/compare/v0.93.1...v0.93.2) (2025-12-18) - - ## [0.93.1](https://github.com/prefabs-tech/fastify/compare/v0.93.0...v0.93.1) (2025-12-09) - - # [0.93.0](https://github.com/prefabs-tech/fastify/compare/v0.92.2...v0.93.0) (2025-11-05) - ### Features -* **slonik**: add support for insensitive (case and accent) filter and sort ([#940](https://github.com/prefabs-tech/fastify/issues/940)) ([c5ac3a2](https://github.com/prefabs-tech/fastify/commit/c5ac3a22977212191fe46d82ad97395ea6e64cc4)) - - +- **slonik**: add support for insensitive (case and accent) filter and sort ([#940](https://github.com/prefabs-tech/fastify/issues/940)) ([c5ac3a2](https://github.com/prefabs-tech/fastify/commit/c5ac3a22977212191fe46d82ad97395ea6e64cc4)) ## [0.92.2](https://github.com/prefabs-tech/fastify/compare/v0.92.1...v0.92.2) (2025-10-30) - ### Bug Fixes -* **firebase:** fix firebase-admin import style to support commonjs and ESM ([#1033](https://github.com/prefabs-tech/fastify/issues/1033)) ([b024dee](https://github.com/prefabs-tech/fastify/commit/b024deecf7ae26f215eb3aaa6bc17f8fbf21bc3b)) - - +- **firebase:** fix firebase-admin import style to support commonjs and ESM ([#1033](https://github.com/prefabs-tech/fastify/issues/1033)) ([b024dee](https://github.com/prefabs-tech/fastify/commit/b024deecf7ae26f215eb3aaa6bc17f8fbf21bc3b)) ## [0.92.1](https://github.com/prefabs-tech/fastify/compare/v0.92.0...v0.92.1) (2025-10-27) - - # [0.92.0](https://github.com/prefabs-tech/fastify/compare/v0.91.1...v0.92.0) (2025-09-26) ### BREAKING CHANGES -* **s3:** update s3 config, AWS S3 client config (access key, secret key etc) are now should be passed into clientConfig +- **s3:** update s3 config, AWS S3 client config (access key, secret key etc) are now should be passed into clientConfig Example: + ```typescript const config: ApiConfig = { // ... other configurations @@ -68,9 +50,10 @@ const config: ApiConfig = { } ``` -* **s3:** UplaodedById is now optional in files model +- **s3:** UplaodedById is now optional in files model ##### Required Migration + If you're upgrading to this version, run the following SQL migration: ```sql @@ -78,15 +61,11 @@ ALTER TABLE files ALTER COLUMN "uploaded_by_id" DROP NOT NULL; ``` - ## [0.91.1](https://github.com/prefabs-tech/fastify/compare/v0.91.0...v0.91.1) (2025-09-23) - ### Bug Fixes -* **slonik:** fix incorrect latitude-longitude order in dwithin filter ([#1023](https://github.com/prefabs-tech/fastify/issues/1023)) ([4f403fb](https://github.com/prefabs-tech/fastify/commit/4f403fbe5a7013169f5052c11661770fd16727f7)) - - +- **slonik:** fix incorrect latitude-longitude order in dwithin filter ([#1023](https://github.com/prefabs-tech/fastify/issues/1023)) ([4f403fb](https://github.com/prefabs-tech/fastify/commit/4f403fbe5a7013169f5052c11661770fd16727f7)) # [0.91.0](https://github.com/prefabs-tech/fastify/compare/v0.90.2...v0.91.0) (2025-09-18) @@ -99,13 +78,13 @@ They are now declared as **peer dependencies**, which means you must install and #### Required Changes -* Install the missing dependencies: +- Install the missing dependencies: ```bash npm install @fastify/cors @fastify/formbody ``` -* Update your server setup: +- Update your server setup: ```typescript import corsPlugin from "@fastify/cors"; @@ -132,7 +111,7 @@ const start = async () => { await fastify.register(formBodyPlugin); ... - + await fastify.listen({ port: config.port, host: "0.0.0.0", @@ -142,28 +121,23 @@ const start = async () => { start(); ``` - ## [0.90.2](https://github.com/prefabs-tech/fastify/compare/v0.90.1...v0.90.2) (2025-09-05) - - ## [0.90.1](https://github.com/prefabs-tech/fastify/compare/v0.90.0...v0.90.1) (2025-08-25) - - # [0.90.0](https://github.com/prefabs-tech/fastify/compare/v0.89.2...v0.90.0) (2025-08-21) ### Breaking changes -* @prefabs.tech/fastify-firebase, @prefabs.tech/fastify-s3, @prefabs.tech/fastify-user now requires @prefabs.tech/fastify-error-handler package for handling http errors and other custom errors. +- @prefabs.tech/fastify-firebase, @prefabs.tech/fastify-s3, @prefabs.tech/fastify-user now requires @prefabs.tech/fastify-error-handler package for handling http errors and other custom errors. ### Bug Fixes -* **user/photo:** fix photo upload ([#1005](https://github.com/prefabs-tech/fastify/issues/1005)) ([f32a4e0](https://github.com/prefabs-tech/fastify/commit/f32a4e06e6947f5c1a0b3ddeb503d4660225de94)) +- **user/photo:** fix photo upload ([#1005](https://github.com/prefabs-tech/fastify/issues/1005)) ([f32a4e0](https://github.com/prefabs-tech/fastify/commit/f32a4e06e6947f5c1a0b3ddeb503d4660225de94)) -* **user/invitation:** create invitaion endpoint now check if role is exists or not, if not exist throws error. +- **user/invitation:** create invitaion endpoint now check if role is exists or not, if not exist throws error. -* **error-handler** fix ErrorResponse fastify schema and update error handler. +- **error-handler** fix ErrorResponse fastify schema and update error handler. ### Error Handling Guidelines @@ -176,20 +150,21 @@ Instead, always throw an error and let the global error handler handle formattin **Wrong** ```ts -fastify.get('/test', async (req, reply) => { +fastify.get("/test", async (req, reply) => { return reply.code(401).send({ message: "Unauthorized" }); -}) +}); ``` **Correct** ```ts -fastify.get('/test', async (req, reply) => { +fastify.get("/test", async (req, reply) => { throw fastify.httpErrors.unauthorized("Unauthorized"); -}) +}); ``` #### Throw `CustomError` (or subclass) + - Modules **must throw** an instance of `CustomError` (or a class extending it). - This ensures errors can be consistently caught and appropriate actions taken. @@ -205,49 +180,33 @@ if (!file) { ## [0.89.2](https://github.com/prefabs-tech/fastify/compare/v0.89.1...v0.89.2) (2025-08-15) - - ## [0.89.1](https://github.com/prefabs-tech/fastify/compare/v0.89.0...v0.89.1) (2025-08-13) - ### Bug Fixes -* **error-handler:** replace stack-trace package with stacktracey to support commonjs ([#1009](https://github.com/prefabs-tech/fastify/issues/1009)) ([05cdb03](https://github.com/prefabs-tech/fastify/commit/05cdb03351066da193d4519183624dea4768c1ca)) - - +- **error-handler:** replace stack-trace package with stacktracey to support commonjs ([#1009](https://github.com/prefabs-tech/fastify/issues/1009)) ([05cdb03](https://github.com/prefabs-tech/fastify/commit/05cdb03351066da193d4519183624dea4768c1ca)) # [0.89.0](https://github.com/prefabs-tech/fastify/compare/v0.88.2...v0.89.0) (2025-08-12) - ### Features -* **error-handler:** Add error handler package ([#1004](https://github.com/prefabs-tech/fastify/issues/1004)) ([aae5dbd](https://github.com/prefabs-tech/fastify/commit/aae5dbdf6718bd575033df9cfda0d40e10a85aae)) - - +- **error-handler:** Add error handler package ([#1004](https://github.com/prefabs-tech/fastify/issues/1004)) ([aae5dbd](https://github.com/prefabs-tech/fastify/commit/aae5dbdf6718bd575033df9cfda0d40e10a85aae)) ## [0.88.2](https://github.com/prefabs-tech/fastify/compare/v0.88.1...v0.88.2) (2025-07-31) - ### Bug Fixes -* **user/photo:** fix photo upload ([#1005](https://github.com/prefabs-tech/fastify/issues/1005)) ([f32a4e0](https://github.com/prefabs-tech/fastify/commit/f32a4e06e6947f5c1a0b3ddeb503d4660225de94)) - - +- **user/photo:** fix photo upload ([#1005](https://github.com/prefabs-tech/fastify/issues/1005)) ([f32a4e0](https://github.com/prefabs-tech/fastify/commit/f32a4e06e6947f5c1a0b3ddeb503d4660225de94)) ## [0.88.1](https://github.com/prefabs-tech/fastify/compare/v0.88.0...v0.88.1) (2025-07-28) - ### Features -* **user:** add photo file size limit in user package ([#1000](https://github.com/prefabs-tech/fastify/issues/1000)) ([5de43a8](https://github.com/prefabs-tech/fastify/commit/5de43a8e2a588c6fba61684b1927c3dd0a3dc8a9)) -* disable email verifation validation for update me route ([de4deb9](https://github.com/prefabs-tech/fastify/commit/de4deb954dce87586bef5c77a742ec2e2ef9bdad)) - - +- **user:** add photo file size limit in user package ([#1000](https://github.com/prefabs-tech/fastify/issues/1000)) ([5de43a8](https://github.com/prefabs-tech/fastify/commit/5de43a8e2a588c6fba61684b1927c3dd0a3dc8a9)) +- disable email verifation validation for update me route ([de4deb9](https://github.com/prefabs-tech/fastify/commit/de4deb954dce87586bef5c77a742ec2e2ef9bdad)) # [0.88.0](https://github.com/prefabs-tech/fastify/compare/v0.87.0...v0.88.0) (2025-07-24) - - > ⚠️ This package was migrated from [`@dzangolab/fastify`](https://github.com/dzangolab/fastify) to [`@prefabs.tech/fastify`](https://github.com/prefabs-tech/fastify). All previous links will redirect. # [0.87.0](https://github.com/dzangolab/fastify/compare/v0.86.1...v0.87.0) (2025-07-21) @@ -261,6 +220,7 @@ if (!file) { - add `s3.bucket` config to upload user photo to desired bucket. if the bucket is not specified the photo won't get uploaded. ##### Required Migration + If you're upgrading to this version, run the following SQL migration: ```sql @@ -273,102 +233,71 @@ ADD CONSTRAINT fk_users_photo ## [0.86.1](https://github.com/dzangolab/fastify/compare/v0.86.0...v0.86.1) (2025-07-16) - - # [0.86.0](https://github.com/dzangolab/fastify/compare/v0.85.1...v0.86.0) (2025-07-07) - ### Features -* add support for dwithin filter ([#984](https://github.com/dzangolab/fastify/issues/984)) ([0c179ab](https://github.com/dzangolab/fastify/commit/0c179abe2945384cec826b3dff35acfc0b00c67b)) - - +- add support for dwithin filter ([#984](https://github.com/dzangolab/fastify/issues/984)) ([0c179ab](https://github.com/dzangolab/fastify/commit/0c179abe2945384cec826b3dff35acfc0b00c67b)) ## [0.85.1](https://github.com/dzangolab/fastify/compare/v0.85.0...v0.85.1) (2025-07-04) - ### Bug Fixes -* **deps:** update dependency @fastify/swagger-ui to v5.2.3 ([#972](https://github.com/dzangolab/fastify/issues/972)) ([5fb3e88](https://github.com/dzangolab/fastify/commit/5fb3e884d1f0c8f6a771157b5d84c3c47ed66a84)) - - +- **deps:** update dependency @fastify/swagger-ui to v5.2.3 ([#972](https://github.com/dzangolab/fastify/issues/972)) ([5fb3e88](https://github.com/dzangolab/fastify/commit/5fb3e884d1f0c8f6a771157b5d84c3c47ed66a84)) # [0.85.0](https://github.com/dzangolab/fastify/compare/v0.84.4...v0.85.0) (2025-07-02) - ### Features -* **slonik:** support hooks in slonik service ([#978](https://github.com/dzangolab/fastify/issues/978)) ([82a42f5](https://github.com/dzangolab/fastify/commit/82a42f5906f60a15faefdb23801dffe5b1be4047)) - - +- **slonik:** support hooks in slonik service ([#978](https://github.com/dzangolab/fastify/issues/978)) ([82a42f5](https://github.com/dzangolab/fastify/commit/82a42f5906f60a15faefdb23801dffe5b1be4047)) ## [0.84.4](https://github.com/dzangolab/fastify/compare/v0.84.3...v0.84.4) (2025-06-20) - ### Bug Fixes -* **deps:** update dependency zod to v3.25.67 ([#963](https://github.com/dzangolab/fastify/issues/963)) ([a544c6b](https://github.com/dzangolab/fastify/commit/a544c6b39ef838f374946ad5657abee113abc06b)) - +- **deps:** update dependency zod to v3.25.67 ([#963](https://github.com/dzangolab/fastify/issues/963)) ([a544c6b](https://github.com/dzangolab/fastify/commit/a544c6b39ef838f374946ad5657abee113abc06b)) ### Features -* add support for filter and sort in joined table column ([#970](https://github.com/dzangolab/fastify/issues/970)) ([c87438c](https://github.com/dzangolab/fastify/commit/c87438cd200a1338a4fd0fc2daa9e105e6d219d9)) - - +- add support for filter and sort in joined table column ([#970](https://github.com/dzangolab/fastify/issues/970)) ([c87438c](https://github.com/dzangolab/fastify/commit/c87438cd200a1338a4fd0fc2daa9e105e6d219d9)) ## [0.84.3](https://github.com/dzangolab/fastify/compare/v0.84.2...v0.84.3) (2025-06-09) - ### Bug Fixes -* **deps:** update aws-sdk-js-v3 monorepo to v3.815.0 ([#895](https://github.com/dzangolab/fastify/issues/895)) ([6796400](https://github.com/dzangolab/fastify/commit/679640090958c5c39a44b787ac628b7315568b6e)) -* **deps:** update dependency @graphql-tools/merge to v9.0.24 ([#956](https://github.com/dzangolab/fastify/issues/956)) ([682a722](https://github.com/dzangolab/fastify/commit/682a722c71bd94c09758c6fc032dc760db1154d5)) -* **deps:** update dependency nodemailer to v6.10.1 ([#958](https://github.com/dzangolab/fastify/issues/958)) ([07af2b9](https://github.com/dzangolab/fastify/commit/07af2b91d030cf814980da2d0d8f857e2acf6935)) -* **deps:** update dependency slonik-interceptor-query-logging to v46.8.0 ([#897](https://github.com/dzangolab/fastify/issues/897)) ([e6f9927](https://github.com/dzangolab/fastify/commit/e6f9927a268ef7f2e61fe18532ce17aa5ea34bdc)) -* fix creating filter fragment for complex nested filter input ([#967](https://github.com/dzangolab/fastify/issues/967)) ([1ff663c](https://github.com/dzangolab/fastify/commit/1ff663c1860b69753ed9bd68d8603bbcf7fcc5c2)) - - +- **deps:** update aws-sdk-js-v3 monorepo to v3.815.0 ([#895](https://github.com/dzangolab/fastify/issues/895)) ([6796400](https://github.com/dzangolab/fastify/commit/679640090958c5c39a44b787ac628b7315568b6e)) +- **deps:** update dependency @graphql-tools/merge to v9.0.24 ([#956](https://github.com/dzangolab/fastify/issues/956)) ([682a722](https://github.com/dzangolab/fastify/commit/682a722c71bd94c09758c6fc032dc760db1154d5)) +- **deps:** update dependency nodemailer to v6.10.1 ([#958](https://github.com/dzangolab/fastify/issues/958)) ([07af2b9](https://github.com/dzangolab/fastify/commit/07af2b91d030cf814980da2d0d8f857e2acf6935)) +- **deps:** update dependency slonik-interceptor-query-logging to v46.8.0 ([#897](https://github.com/dzangolab/fastify/issues/897)) ([e6f9927](https://github.com/dzangolab/fastify/commit/e6f9927a268ef7f2e61fe18532ce17aa5ea34bdc)) +- fix creating filter fragment for complex nested filter input ([#967](https://github.com/dzangolab/fastify/issues/967)) ([1ff663c](https://github.com/dzangolab/fastify/commit/1ff663c1860b69753ed9bd68d8603bbcf7fcc5c2)) ## [0.84.2](https://github.com/dzangolab/fastify/compare/v0.84.1...v0.84.2) (2025-05-22) - ### Bug Fixes -* **deps:** update dependency zod to v3.25.20 ([#898](https://github.com/dzangolab/fastify/issues/898)) ([b95b44a](https://github.com/dzangolab/fastify/commit/b95b44aec9fe5c5a640c44c7e323945504a7b6a0)) - +- **deps:** update dependency zod to v3.25.20 ([#898](https://github.com/dzangolab/fastify/issues/898)) ([b95b44a](https://github.com/dzangolab/fastify/commit/b95b44aec9fe5c5a640c44c7e323945504a7b6a0)) ### Features -* add fastify schema for rest routes ([#954](https://github.com/dzangolab/fastify/issues/954)) ([19cc070](https://github.com/dzangolab/fastify/commit/19cc070934728305110ae01983e9a14da9741cdc)) - - +- add fastify schema for rest routes ([#954](https://github.com/dzangolab/fastify/issues/954)) ([19cc070](https://github.com/dzangolab/fastify/commit/19cc070934728305110ae01983e9a14da9741cdc)) ## [0.84.1](https://github.com/dzangolab/fastify/compare/v0.84.0...v0.84.1) (2025-05-19) - ### Features -* **s3:** add ajv file plugin to use @fastify/multipart with @fastify/swagger for body validation ([#952](https://github.com/dzangolab/fastify/issues/952)) ([0bd341d](https://github.com/dzangolab/fastify/commit/0bd341d342bdab2231db79266bfd8947cce2cb55)) - - +- **s3:** add ajv file plugin to use @fastify/multipart with @fastify/swagger for body validation ([#952](https://github.com/dzangolab/fastify/issues/952)) ([0bd341d](https://github.com/dzangolab/fastify/commit/0bd341d342bdab2231db79266bfd8947cce2cb55)) # [0.84.0](https://github.com/dzangolab/fastify/compare/v0.83.0...v0.84.0) (2025-05-14) - ### Features -* **swagger:** add fastify-swagger package ([#950](https://github.com/dzangolab/fastify/issues/950)) ([eddd1ce](https://github.com/dzangolab/fastify/commit/eddd1ceefa14f8d80cd46f0cc385527a4d6851e3)) - - +- **swagger:** add fastify-swagger package ([#950](https://github.com/dzangolab/fastify/issues/950)) ([eddd1ce](https://github.com/dzangolab/fastify/commit/eddd1ceefa14f8d80cd46f0cc385527a4d6851e3)) # [0.83.0](https://github.com/dzangolab/fastify/compare/v0.82.0...v0.83.0) (2025-05-09) - ### Features -* **user:** add deleteMe graphql mutation ([#946](https://github.com/dzangolab/fastify/issues/946)) ([5fbb582](https://github.com/dzangolab/fastify/commit/5fbb58208252b0d7c192e43004292aa5bb958a0e)) - - +- **user:** add deleteMe graphql mutation ([#946](https://github.com/dzangolab/fastify/issues/946)) ([5fbb582](https://github.com/dzangolab/fastify/commit/5fbb58208252b0d7c192e43004292aa5bb958a0e)) # [0.82.0](https://github.com/dzangolab/fastify/compare/v0.81.0...v0.82.0) (2025-05-08) @@ -381,6 +310,7 @@ ADD CONSTRAINT fk_users_photo - This requires a new column deleted_at to be added to the users table (or your custom user table if overridden). ##### Required Migration + If you're upgrading to this version, run the following SQL migration: ```sql @@ -390,35 +320,30 @@ ADD "deleted_at" timestamp NULL; ### Features -* **user:** delete my user account ([#944](https://github.com/dzangolab/fastify/issues/944)) ([ddf6eb2](https://github.com/dzangolab/fastify/commit/ddf6eb2ae778e991cc6d476447515a71440152fe)) -* **user:** support filter in roles column ([#943](https://github.com/dzangolab/fastify/issues/943)) ([1af08d6](https://github.com/dzangolab/fastify/commit/1af08d6f4684f13fc1414c5b13c23de8593ed99f)) - - +- **user:** delete my user account ([#944](https://github.com/dzangolab/fastify/issues/944)) ([ddf6eb2](https://github.com/dzangolab/fastify/commit/ddf6eb2ae778e991cc6d476447515a71440152fe)) +- **user:** support filter in roles column ([#943](https://github.com/dzangolab/fastify/issues/943)) ([1af08d6](https://github.com/dzangolab/fastify/commit/1af08d6f4684f13fc1414c5b13c23de8593ed99f)) # [0.81.0](https://github.com/dzangolab/fastify/compare/v0.80.1...v0.81.0) (2025-04-25) - ### Features -* **slonik:** add soft delete feature ([#931](https://github.com/dzangolab/fastify/issues/931)) ([69a0351](https://github.com/dzangolab/fastify/commit/69a0351fdb4faaf50ce83710129a3db8de051a52)) - +- **slonik:** add soft delete feature ([#931](https://github.com/dzangolab/fastify/issues/931)) ([69a0351](https://github.com/dzangolab/fastify/commit/69a0351fdb4faaf50ce83710129a3db8de051a52)) ### Performance Improvements -* update services and sql factories ([#926](https://github.com/dzangolab/fastify/issues/926)) ([08bc0d1](https://github.com/dzangolab/fastify/commit/08bc0d1c8ef9f9ad98768ffbd2a1e0359f580204)) - +- update services and sql factories ([#926](https://github.com/dzangolab/fastify/issues/926)) ([08bc0d1](https://github.com/dzangolab/fastify/commit/08bc0d1c8ef9f9ad98768ffbd2a1e0359f580204)) ### BREAKING CHANGES -* Removed generic types from SqlFactory. -* Moved database configuration into SqlFactory. Static properties like TABLE, LIMIT_DEFAULT, and SORT_KEY must now be defined inside each factory class. -* Required every entity to have a corresponding SqlFactory (at minimum, to define the TABLE name). -* Converted instance property methods into publicly overridable methods. -* Removed dependency on QueryResultRow -* Updated services to use entity types directly instead of generics. - +- Removed generic types from SqlFactory. +- Moved database configuration into SqlFactory. Static properties like TABLE, LIMIT_DEFAULT, and SORT_KEY must now be defined inside each factory class. +- Required every entity to have a corresponding SqlFactory (at minimum, to define the TABLE name). +- Converted instance property methods into publicly overridable methods. +- Removed dependency on QueryResultRow +- Updated services to use entity types directly instead of generics. #### Example of the new SqlFactory and Service pattern: + ```ts import { DefaultSqlFactory } from "@dzangolab/fastify-slonik"; @@ -433,17 +358,9 @@ export default UserSqlFactory; import { BaseService } from "@dzangolab/fastify-slonik"; import UserSqlFactory from "./sqlFactory"; -import { - User, - UserCreateInput, - UserUpdateInput, -} from "../../types"; - -class UserService extends BaseService< - User, - UsereCreateInput, - UserUpdateInput -> { +import { User, UserCreateInput, UserUpdateInput } from "../../types"; + +class UserService extends BaseService { get factory(): UserSqlFactory { return super.factory as UserSqlFactory; } @@ -457,12 +374,9 @@ export default UserService; ``` #### Extending functionality by overriding methods: + ```ts -class UserService extends BaseService< - User, - UsereCreateInput, - UserUpdateInput -> { +class UserService extends BaseService { async update(data: C): Promise { const user = await super.update(data); @@ -476,219 +390,168 @@ export default UserService; ``` ### Migration Guide (for upgrading from 0.80.1 or earlier) -* Ensure every entity has a associated SqlFactory (at minimum, to define the `TABLE` name). -* Remove generic types from SqlFactory definitions. -* Move all the database config inside SqlFactory (static properties like TABLE, LIMIT_DEFAULT, and SORT_KEY) -* Refactor service methods to be publicly overridable instead of instance properties. -* Update services to use the entity type directly, instead of relying on generics. -Refer to: +- Ensure every entity has a associated SqlFactory (at minimum, to define the `TABLE` name). +- Remove generic types from SqlFactory definitions. +- Move all the database config inside SqlFactory (static properties like TABLE, LIMIT_DEFAULT, and SORT_KEY) +- Refactor service methods to be publicly overridable instead of instance properties. +- Update services to use the entity type directly, instead of relying on generics. -* [InviationSqlFactory changes](https://github.com/dzangolab/fastify/pull/926/files#diff-47f88ff17d3c11a7866b0cd7bfef5d7666de929bfcd0baf78b3fa1d87fb9e9e5) +Refer to: -* [InvitationService changes](https://github.com/dzangolab/fastify/pull/926/files#diff-9783f01520622eb1f26b8425c84e8c361d7b1988432734ce1c794fd7e580b917) +- [InviationSqlFactory changes](https://github.com/dzangolab/fastify/pull/926/files#diff-47f88ff17d3c11a7866b0cd7bfef5d7666de929bfcd0baf78b3fa1d87fb9e9e5) +- [InvitationService changes](https://github.com/dzangolab/fastify/pull/926/files#diff-9783f01520622eb1f26b8425c84e8c361d7b1988432734ce1c794fd7e580b917) ## [0.80.1](https://github.com/dzangolab/fastify/compare/v0.80.0...v0.80.1) (2025-04-23) - - # [0.80.0](https://github.com/dzangolab/fastify/compare/v0.79.0...v0.80.0) (2025-04-04) - ### BREAKING CHANGES -* Requires Fastify >=5.2.1. See [V5 Migration Guide](https://fastify.dev/docs/latest/Guides/Migration-Guide-V5) for more details. -* Fastify v5 will only support Node.js v20+. -* @dzangolab/multi-tenant package is deprecated. +- Requires Fastify >=5.2.1. See [V5 Migration Guide](https://fastify.dev/docs/latest/Guides/Migration-Guide-V5) for more details. +- Fastify v5 will only support Node.js v20+. +- @dzangolab/multi-tenant package is deprecated. ### Fixes -* **deps:** update fastify to >=5.2.1 ([#915](https://github.com/dzangolab/fastify/issues/915)) ([38617b3](https://github.com/dzangolab/fastify/commit/b15e5aec71dc2fc3c068ca5c3d0e7dde6237d12d)) - -* **deprecate:** chore: mark multi-tenant package as deprecated ([#918](https://github.com/dzangolab/fastify/issues/918)) ([38617b3](https://github.com/dzangolab/fastify/commit/f2762a0509c9d69bb89c0fca31589a436d10c0b1)) +- **deps:** update fastify to >=5.2.1 ([#915](https://github.com/dzangolab/fastify/issues/915)) ([38617b3](https://github.com/dzangolab/fastify/commit/b15e5aec71dc2fc3c068ca5c3d0e7dde6237d12d)) +- **deprecate:** chore: mark multi-tenant package as deprecated ([#918](https://github.com/dzangolab/fastify/issues/918)) ([38617b3](https://github.com/dzangolab/fastify/commit/f2762a0509c9d69bb89c0fca31589a436d10c0b1)) # [0.79.0](https://github.com/dzangolab/fastify/compare/v0.78.0...v0.79.0) (2025-03-11) ### Bug Fixes -* **deps:** update dependency nodemailer to v6.10.0 ([#896](https://github.com/dzangolab/fastify/issues/896)) ([38617b3](https://github.com/dzangolab/fastify/commit/38617b3125bc93e3c7f3432a8032791e42840342)) -* remove unnecessary columns from the user migration ([#908](https://github.com/dzangolab/fastify/issues/908)) ([3a71752](https://github.com/dzangolab/fastify/commit/3a7175205c1d95b2ee5588c9efd6afb899e430d7)) - +- **deps:** update dependency nodemailer to v6.10.0 ([#896](https://github.com/dzangolab/fastify/issues/896)) ([38617b3](https://github.com/dzangolab/fastify/commit/38617b3125bc93e3c7f3432a8032791e42840342)) +- remove unnecessary columns from the user migration ([#908](https://github.com/dzangolab/fastify/issues/908)) ([3a71752](https://github.com/dzangolab/fastify/commit/3a7175205c1d95b2ee5588c9efd6afb899e430d7)) ### Features -* add users and invitations migrations ([#905](https://github.com/dzangolab/fastify/issues/905)) ([09d4423](https://github.com/dzangolab/fastify/commit/09d4423bac12422f6eb6cc62ccfb1f0b4f0ab913)) -* allow user to add additional roles ([#907](https://github.com/dzangolab/fastify/issues/907)) ([0a41dd7](https://github.com/dzangolab/fastify/commit/0a41dd74aef826cc4841995b59a6f34491d1464e)) - - +- add users and invitations migrations ([#905](https://github.com/dzangolab/fastify/issues/905)) ([09d4423](https://github.com/dzangolab/fastify/commit/09d4423bac12422f6eb6cc62ccfb1f0b4f0ab913)) +- allow user to add additional roles ([#907](https://github.com/dzangolab/fastify/issues/907)) ([0a41dd7](https://github.com/dzangolab/fastify/commit/0a41dd74aef826cc4841995b59a6f34491d1464e)) # [0.78.0](https://github.com/dzangolab/fastify/compare/v0.77.7...v0.78.0) (2025-03-07) - ### Features -* customizable email subject from config ([#902](https://github.com/dzangolab/fastify/issues/902)) ([3b7600a](https://github.com/dzangolab/fastify/commit/3b7600aeb3beed730c26665c608fcafe2c6753d2)) - - +- customizable email subject from config ([#902](https://github.com/dzangolab/fastify/issues/902)) ([3b7600a](https://github.com/dzangolab/fastify/commit/3b7600aeb3beed730c26665c608fcafe2c6753d2)) ## [0.77.7](https://github.com/dzangolab/fastify/compare/v0.77.6...v0.77.7) (2025-02-19) - ### Bug Fixes -* fix plugins being registered mixes async and callback styles ([#891](https://github.com/dzangolab/fastify/issues/891)) ([b0a1140](https://github.com/dzangolab/fastify/commit/b0a1140523c1df6cbce5148a3880904fa524dc56)) - - +- fix plugins being registered mixes async and callback styles ([#891](https://github.com/dzangolab/fastify/issues/891)) ([b0a1140](https://github.com/dzangolab/fastify/commit/b0a1140523c1df6cbce5148a3880904fa524dc56)) ## [0.77.6](https://github.com/dzangolab/fastify/compare/v0.77.5...v0.77.6) (2025-02-19) - ### Bug Fixes -* **deps:** update dependency @graphql-tools/merge to v9.0.19 ([#835](https://github.com/dzangolab/fastify/issues/835)) ([c9c25fd](https://github.com/dzangolab/fastify/commit/c9c25fdd188689e8e872d6ba040d764ad77fd74f)) -* **deps:** update dependency nodemailer-mjml to v1.4.12 ([#876](https://github.com/dzangolab/fastify/issues/876)) ([1f3bdd2](https://github.com/dzangolab/fastify/commit/1f3bdd2fa3bb3a83ed168cc2022e2c24fd146805)) - - +- **deps:** update dependency @graphql-tools/merge to v9.0.19 ([#835](https://github.com/dzangolab/fastify/issues/835)) ([c9c25fd](https://github.com/dzangolab/fastify/commit/c9c25fdd188689e8e872d6ba040d764ad77fd74f)) +- **deps:** update dependency nodemailer-mjml to v1.4.12 ([#876](https://github.com/dzangolab/fastify/issues/876)) ([1f3bdd2](https://github.com/dzangolab/fastify/commit/1f3bdd2fa3bb3a83ed168cc2022e2c24fd146805)) ## [0.77.5](https://github.com/dzangolab/fastify/compare/v0.77.4...v0.77.5) (2025-02-18) - - ## [0.77.4](https://github.com/dzangolab/fastify/compare/v0.77.3...v0.77.4) (2025-02-14) - - ## [0.77.3](https://github.com/dzangolab/fastify/compare/v0.77.2...v0.77.3) (2025-02-03) - - ## [0.77.2](https://github.com/dzangolab/fastify/compare/v0.77.1...v0.77.2) (2025-01-27) - ### Bug Fixes -* update verify email utility function ([3b9b77e](https://github.com/dzangolab/fastify/commit/3b9b77e5c9dd8e35b71c836584dcc7b0b2ff485c)) - - +- update verify email utility function ([3b9b77e](https://github.com/dzangolab/fastify/commit/3b9b77e5c9dd8e35b71c836584dcc7b0b2ff485c)) ## [0.77.1](https://github.com/dzangolab/fastify/compare/v0.77.0...v0.77.1) (2025-01-27) - ### Bug Fixes -* update verify email utility to support user context argument ([1391117](https://github.com/dzangolab/fastify/commit/1391117b6edb3ca6b56af18551fb1c4426ac723e)) - - +- update verify email utility to support user context argument ([1391117](https://github.com/dzangolab/fastify/commit/1391117b6edb3ca6b56af18551fb1c4426ac723e)) # [0.77.0](https://github.com/dzangolab/fastify/compare/v0.76.4...v0.77.0) (2025-01-03) - ### Features -* **user:** add change email mutation for graphql ([#856](https://github.com/dzangolab/fastify/issues/856)) ([fbfa956](https://github.com/dzangolab/fastify/commit/fbfa956ec872f2d8d9ceec8f3f5e51a2ec2e0a7f)) - - +- **user:** add change email mutation for graphql ([#856](https://github.com/dzangolab/fastify/issues/856)) ([fbfa956](https://github.com/dzangolab/fastify/commit/fbfa956ec872f2d8d9ceec8f3f5e51a2ec2e0a7f)) ## [0.76.4](https://github.com/dzangolab/fastify/compare/v0.76.3...v0.76.4) (2024-12-31) - ### Features -* **user:** include thirdParty information in user response ([#854](https://github.com/dzangolab/fastify/issues/854)) ([1039b92](https://github.com/dzangolab/fastify/commit/1039b9226f7c4c34782b8b7026bb207bf45c9046)) - - +- **user:** include thirdParty information in user response ([#854](https://github.com/dzangolab/fastify/issues/854)) ([1039b92](https://github.com/dzangolab/fastify/commit/1039b9226f7c4c34782b8b7026bb207bf45c9046)) ## [0.76.3](https://github.com/dzangolab/fastify/compare/v0.76.2...v0.76.3) (2024-12-25) -* **user:** fix email verification link for change email ([#852](https://github.com/dzangolab/fastify/issues/852)) ([1ac20c3](https://github.com/dzangolab/fastify/commit/1ac20c3927c23c6c489f8505162276180045f6e9)) +- **user:** fix email verification link for change email ([#852](https://github.com/dzangolab/fastify/issues/852)) ([1ac20c3](https://github.com/dzangolab/fastify/commit/1ac20c3927c23c6c489f8505162276180045f6e9)) ## [0.76.2](https://github.com/dzangolab/fastify/compare/v0.76.1...v0.76.2) (2024-12-24) - ### Features -* **slonik:** add find and findOne method in service class ([#850](https://github.com/dzangolab/fastify/issues/850)) ([337bdf3](https://github.com/dzangolab/fastify/commit/337bdf33f20453eb4ec00393c2e67703f0d6cd16)) +- **slonik:** add find and findOne method in service class ([#850](https://github.com/dzangolab/fastify/issues/850)) ([337bdf3](https://github.com/dzangolab/fastify/commit/337bdf33f20453eb4ec00393c2e67703f0d6cd16)) 1ac20c3927c23c6c489f8505162276180045f6e9 ## [0.76.1](https://github.com/dzangolab/fastify/compare/v0.76.0...v0.76.1) (2024-12-19) - ### Performance Improvements -* **user:** update email if new email is different than current ([#846](https://github.com/dzangolab/fastify/issues/846)) ([4a56e00](https://github.com/dzangolab/fastify/commit/4a56e005560f2a9b61ea003b6404e37bb2ee254e)) - - +- **user:** update email if new email is different than current ([#846](https://github.com/dzangolab/fastify/issues/846)) ([4a56e00](https://github.com/dzangolab/fastify/commit/4a56e005560f2a9b61ea003b6404e37bb2ee254e)) # [0.76.0](https://github.com/dzangolab/fastify/compare/v0.75.5...v0.76.0) (2024-12-18) - ### Features -* **user:** add rest endpoint to update email ([#841](https://github.com/dzangolab/fastify/issues/841)) ([17295e8](https://github.com/dzangolab/fastify/commit/17295e8ca77f81ea2f74bb846b0ec2cd1f4cae87)) -* **user:** add config to toggle email update feature ([#844](https://github.com/dzangolab/fastify/issues/844)) ([f828372](https://github.com/dzangolab/fastify/commit/f82837201f2a57087119751be524ee8354f169b0)) -* **user:** allow user to update email in case of unverified current email ([#843](https://github.com/dzangolab/fastify/issues/843)) ([79575d0](https://github.com/dzangolab/fastify/commit/79575d082f6dc647d8c40e988f9f3a92d6a61a02)) -* **user:** disallow update email if user with same email already exists ([#842](https://github.com/dzangolab/fastify/issues/842)) ([791fa30](https://github.com/dzangolab/fastify/commit/791fa30422cb144ea941614f5bf6651c6cb1acca)) - +- **user:** add rest endpoint to update email ([#841](https://github.com/dzangolab/fastify/issues/841)) ([17295e8](https://github.com/dzangolab/fastify/commit/17295e8ca77f81ea2f74bb846b0ec2cd1f4cae87)) +- **user:** add config to toggle email update feature ([#844](https://github.com/dzangolab/fastify/issues/844)) ([f828372](https://github.com/dzangolab/fastify/commit/f82837201f2a57087119751be524ee8354f169b0)) +- **user:** allow user to update email in case of unverified current email ([#843](https://github.com/dzangolab/fastify/issues/843)) ([79575d0](https://github.com/dzangolab/fastify/commit/79575d082f6dc647d8c40e988f9f3a92d6a61a02)) +- **user:** disallow update email if user with same email already exists ([#842](https://github.com/dzangolab/fastify/issues/842)) ([791fa30](https://github.com/dzangolab/fastify/commit/791fa30422cb144ea941614f5bf6651c6cb1acca)) ## [0.75.5](https://github.com/dzangolab/fastify/compare/v0.75.4...v0.75.5) (2024-12-04) - ### Bug Fixes -* **deps:** update aws-sdk-js-v3 monorepo to v3.701.0 ([#747](https://github.com/dzangolab/fastify/issues/747)) ([47a7c34](https://github.com/dzangolab/fastify/commit/47a7c34576464f3b3353cada5259c1ec0a9eca66)) -* **deps:** update dependency @graphql-tools/merge to v9.0.11 ([#826](https://github.com/dzangolab/fastify/issues/826)) ([020d7dd](https://github.com/dzangolab/fastify/commit/020d7ddaf8a06d18097d5701b5c76a79ea1c3894)) -* **deps:** update dependency firebase-admin to v12.7.0 ([#817](https://github.com/dzangolab/fastify/issues/817)) ([bc1db41](https://github.com/dzangolab/fastify/commit/bc1db418a6d2918eeb39816069baa05b48cc5011)) -* **deps:** update dependency graphql-upload-minimal to v1.6.1 ([#751](https://github.com/dzangolab/fastify/issues/751)) ([d074b80](https://github.com/dzangolab/fastify/commit/d074b80e0991b6c136f6d4a73907fd0c4ff72c05)) -* **deps:** update dependency nodemailer-mjml to v1.4.7 ([#796](https://github.com/dzangolab/fastify/issues/796)) ([17e6a6f](https://github.com/dzangolab/fastify/commit/17e6a6f87ae0af5293d4b26782fe1f1fa3e0ec42)) -* **deps:** update dependency pg to v8.13.1 ([#767](https://github.com/dzangolab/fastify/issues/767)) ([0241f5e](https://github.com/dzangolab/fastify/commit/0241f5ecedc1cbb9ca6914990e4e0e98f45b57e1)) -* error response for package endpoints ([#822](https://github.com/dzangolab/fastify/issues/822)) ([e520d8c](https://github.com/dzangolab/fastify/commit/e520d8c36c7dc57fe022b1b1aee29173630d6ee6)) - - +- **deps:** update aws-sdk-js-v3 monorepo to v3.701.0 ([#747](https://github.com/dzangolab/fastify/issues/747)) ([47a7c34](https://github.com/dzangolab/fastify/commit/47a7c34576464f3b3353cada5259c1ec0a9eca66)) +- **deps:** update dependency @graphql-tools/merge to v9.0.11 ([#826](https://github.com/dzangolab/fastify/issues/826)) ([020d7dd](https://github.com/dzangolab/fastify/commit/020d7ddaf8a06d18097d5701b5c76a79ea1c3894)) +- **deps:** update dependency firebase-admin to v12.7.0 ([#817](https://github.com/dzangolab/fastify/issues/817)) ([bc1db41](https://github.com/dzangolab/fastify/commit/bc1db418a6d2918eeb39816069baa05b48cc5011)) +- **deps:** update dependency graphql-upload-minimal to v1.6.1 ([#751](https://github.com/dzangolab/fastify/issues/751)) ([d074b80](https://github.com/dzangolab/fastify/commit/d074b80e0991b6c136f6d4a73907fd0c4ff72c05)) +- **deps:** update dependency nodemailer-mjml to v1.4.7 ([#796](https://github.com/dzangolab/fastify/issues/796)) ([17e6a6f](https://github.com/dzangolab/fastify/commit/17e6a6f87ae0af5293d4b26782fe1f1fa3e0ec42)) +- **deps:** update dependency pg to v8.13.1 ([#767](https://github.com/dzangolab/fastify/issues/767)) ([0241f5e](https://github.com/dzangolab/fastify/commit/0241f5ecedc1cbb9ca6914990e4e0e98f45b57e1)) +- error response for package endpoints ([#822](https://github.com/dzangolab/fastify/issues/822)) ([e520d8c](https://github.com/dzangolab/fastify/commit/e520d8c36c7dc57fe022b1b1aee29173630d6ee6)) ## [0.75.4](https://github.com/dzangolab/fastify/compare/v0.75.3...v0.75.4) (2024-11-25) - ### Bug Fixes -* **multi-tenant:** fix change password by account user ([#698](https://github.com/dzangolab/fastify/issues/698)) ([6fae887](https://github.com/dzangolab/fastify/commit/6fae8877f0f50214ef64d250f0010cfbc2819a38)) - - +- **multi-tenant:** fix change password by account user ([#698](https://github.com/dzangolab/fastify/issues/698)) ([6fae887](https://github.com/dzangolab/fastify/commit/6fae8877f0f50214ef64d250f0010cfbc2819a38)) ## [0.75.3](https://github.com/dzangolab/fastify/compare/v0.75.2...v0.75.3) (2024-11-20) - ### Bug Fixes -* **deps:** update dependency nodemailer to v6.9.16 ([#812](https://github.com/dzangolab/fastify/issues/812)) ([446f00c](https://github.com/dzangolab/fastify/commit/446f00ca13b82395ae33132bd648022ff4ac42c4)) -* **multi-tenant:** fix change schema query ([fb93d02](https://github.com/dzangolab/fastify/commit/fb93d02e815f9fc1054979622939e6a680e9aee7)) - - +- **deps:** update dependency nodemailer to v6.9.16 ([#812](https://github.com/dzangolab/fastify/issues/812)) ([446f00c](https://github.com/dzangolab/fastify/commit/446f00ca13b82395ae33132bd648022ff4ac42c4)) +- **multi-tenant:** fix change schema query ([fb93d02](https://github.com/dzangolab/fastify/commit/fb93d02e815f9fc1054979622939e6a680e9aee7)) ## [0.75.2](https://github.com/dzangolab/fastify/compare/v0.75.1...v0.75.2) (2024-11-07) ### Features -* **slonik:** support query logging ([#786](https://github.com/dzangolab/fastify/issues/805)) ([1be2670](https://github.com/dzangolab/fastify/commit/1be2670eb5a8d31d21ad51673388a91cbe29b5f2)) +- **slonik:** support query logging ([#786](https://github.com/dzangolab/fastify/issues/805)) ([1be2670](https://github.com/dzangolab/fastify/commit/1be2670eb5a8d31d21ad51673388a91cbe29b5f2)) ## [0.75.1](https://github.com/dzangolab/fastify/compare/v0.75.0...v0.75.1) (2024-11-06) - ### Bug Fixes -* **user:** skip email verification check for get me route ([#806](https://github.com/dzangolab/fastify/issues/806)) ([bde5d07](https://github.com/dzangolab/fastify/commit/bde5d07e66a0eab639acf72180f18ab2dcd1f5be)) - - +- **user:** skip email verification check for get me route ([#806](https://github.com/dzangolab/fastify/issues/806)) ([bde5d07](https://github.com/dzangolab/fastify/commit/bde5d07e66a0eab639acf72180f18ab2dcd1f5be)) # [0.75.0](https://github.com/dzangolab/fastify/compare/v0.74.1...v0.75.0) (2024-10-30) ### BREAKING CHANGES -* (slonik): Removes createMockPool, Dev should use database connection instead of mocking. -* (slonik): Removed config to disable slonik package migration .i.e. `migrations.package` is removed from SlonikOptions. -* (slonik): Removed migration to auto update updated_at column for tables that that updated_at column in all schema but you can still run this sql from application or directly in postgres +- (slonik): Removes createMockPool, Dev should use database connection instead of mocking. +- (slonik): Removed config to disable slonik package migration .i.e. `migrations.package` is removed from SlonikOptions. +- (slonik): Removed migration to auto update updated_at column for tables that that updated_at column in all schema but you can still run this sql from application or directly in postgres + ```sql /* Update updated_at column for a table. */ CREATE OR REPLACE FUNCTION update_updated_at_column() @@ -775,1720 +638,1184 @@ Refer to: ## [0.74.1](https://github.com/dzangolab/fastify/compare/v0.74.0...v0.74.1) (2024-10-23) - ### Bug Fixes -* **deps:** update dependency @graphql-tools/merge to v9.0.8 ([#795](https://github.com/dzangolab/fastify/issues/795)) ([16aaee2](https://github.com/dzangolab/fastify/commit/16aaee21028c43250064e9da582de7ac24c450d2)) -* **deps:** update turbo monorepo to v2.1.3 ([#763](https://github.com/dzangolab/fastify/issues/763)) ([ba1624b](https://github.com/dzangolab/fastify/commit/ba1624bd325688c08185a48bca929ce04f324221)) - +- **deps:** update dependency @graphql-tools/merge to v9.0.8 ([#795](https://github.com/dzangolab/fastify/issues/795)) ([16aaee2](https://github.com/dzangolab/fastify/commit/16aaee21028c43250064e9da582de7ac24c450d2)) +- **deps:** update turbo monorepo to v2.1.3 ([#763](https://github.com/dzangolab/fastify/issues/763)) ([ba1624b](https://github.com/dzangolab/fastify/commit/ba1624bd325688c08185a48bca929ce04f324221)) ### Features -* **firebase:** support options as argument by firebase plugin ([#791](https://github.com/dzangolab/fastify/issues/791)) ([653ab96](https://github.com/dzangolab/fastify/commit/653ab969be99d4d0d8bc71e8beac4eb06695645c)) -* **slonik:** support options as argument by slonik plugin ([#786](https://github.com/dzangolab/fastify/issues/786)) ([fb1097d](https://github.com/dzangolab/fastify/commit/fb1097d1ab0d9a68563da54a59475305c27990be)) - - +- **firebase:** support options as argument by firebase plugin ([#791](https://github.com/dzangolab/fastify/issues/791)) ([653ab96](https://github.com/dzangolab/fastify/commit/653ab969be99d4d0d8bc71e8beac4eb06695645c)) +- **slonik:** support options as argument by slonik plugin ([#786](https://github.com/dzangolab/fastify/issues/786)) ([fb1097d](https://github.com/dzangolab/fastify/commit/fb1097d1ab0d9a68563da54a59475305c27990be)) # [0.74.0](https://github.com/dzangolab/fastify/compare/v0.73.1...v0.74.0) (2024-10-04) - ### BREAKING CHANGES -* By default, the package automatically registers its routes. However, route registration can be disabled if needed. +- By default, the package automatically registers its routes. However, route registration can be disabled if needed. ### Features -* **graphql:** support options as argument by graphql plugin ([#779](https://github.com/dzangolab/fastify/issues/779)) ([b6faf81](https://github.com/dzangolab/fastify/commit/b6faf81c0f63b684f251b4e0dd25c43d707fb19b)) - +- **graphql:** support options as argument by graphql plugin ([#779](https://github.com/dzangolab/fastify/issues/779)) ([b6faf81](https://github.com/dzangolab/fastify/commit/b6faf81c0f63b684f251b4e0dd25c43d707fb19b)) ### Reverts -* Revert "pnpm: link-workspace-packages to default value (#766)" (#783) ([85688fe](https://github.com/dzangolab/fastify/commit/85688fed3eb33d4cf7cd6d6b04b5197f89cca19f)), closes [#766](https://github.com/dzangolab/fastify/issues/766) [#783](https://github.com/dzangolab/fastify/issues/783) - - +- Revert "pnpm: link-workspace-packages to default value (#766)" (#783) ([85688fe](https://github.com/dzangolab/fastify/commit/85688fed3eb33d4cf7cd6d6b04b5197f89cca19f)), closes [#766](https://github.com/dzangolab/fastify/issues/766) [#783](https://github.com/dzangolab/fastify/issues/783) ## [0.73.1](https://github.com/dzangolab/fastify/compare/v0.73.0...v0.73.1) (2024-09-25) - ### Features -* **user:** support custom prefix for Supertokens API routes ([#775](https://github.com/dzangolab/fastify/issues/775)) ([b286a20](https://github.com/dzangolab/fastify/commit/b286a200ba558abafacb50c26946f6e91d3f228d)) - - +- **user:** support custom prefix for Supertokens API routes ([#775](https://github.com/dzangolab/fastify/issues/775)) ([b286a20](https://github.com/dzangolab/fastify/commit/b286a200ba558abafacb50c26946f6e91d3f228d)) # [0.73.0](https://github.com/dzangolab/fastify/compare/v0.72.1...v0.73.0) (2024-09-23) - ### Features -* **mailer:** add support for passing options as arguments to mailer plugin ([#772](https://github.com/dzangolab/fastify/issues/772)) ([3211ef0](https://github.com/dzangolab/fastify/commit/3211ef04aba68ba48093adca9e7b13886868cb72)) - - +- **mailer:** add support for passing options as arguments to mailer plugin ([#772](https://github.com/dzangolab/fastify/issues/772)) ([3211ef0](https://github.com/dzangolab/fastify/commit/3211ef04aba68ba48093adca9e7b13886868cb72)) ## [0.72.1](https://github.com/dzangolab/fastify/compare/v0.72.0...v0.72.1) (2024-09-11) - - # [0.72.0](https://github.com/dzangolab/fastify/compare/v0.71.3...v0.72.0) (2024-09-11) ### Features -* **slonik:** add support for custom sql factory class ([#742](https://github.com/dzangolab/fastify/issues/742)) ([4d63632](https://github.com/dzangolab/fastify/commit/4d63632b83916d3ffbd5616499b64eb2d43151ca)) - - +- **slonik:** add support for custom sql factory class ([#742](https://github.com/dzangolab/fastify/issues/742)) ([4d63632](https://github.com/dzangolab/fastify/commit/4d63632b83916d3ffbd5616499b64eb2d43151ca)) ## [0.71.3](https://github.com/dzangolab/fastify/compare/v0.71.2...v0.71.3) (2024-08-28) - ### Bug Fixes -* supress ts error not relevent to the current package ([a2a63b6](https://github.com/dzangolab/fastify/commit/a2a63b6a5c4124da2ea788425df78e4e7590cb0a)) - - +- supress ts error not relevent to the current package ([a2a63b6](https://github.com/dzangolab/fastify/commit/a2a63b6a5c4124da2ea788425df78e4e7590cb0a)) ## [0.71.2](https://github.com/dzangolab/fastify/compare/v0.71.1...v0.71.2) (2024-08-19) - - ## [0.71.1](https://github.com/dzangolab/fastify/compare/v0.71.0...v0.71.1) (2024-08-14) - ### Bug Fixes -* support removing graphql related packages when not used ([#719](https://github.com/dzangolab/fastify/issues/719)) ([05ffba1](https://github.com/dzangolab/fastify/commit/05ffba1db895faeef7eb21b4ae06c1ea307a2cae)) - - +- support removing graphql related packages when not used ([#719](https://github.com/dzangolab/fastify/issues/719)) ([05ffba1](https://github.com/dzangolab/fastify/commit/05ffba1db895faeef7eb21b4ae06c1ea307a2cae)) # [0.71.0](https://github.com/dzangolab/fastify/compare/v0.70.0...v0.71.0) (2024-08-02) - # [0.70.0](https://github.com/dzangolab/fastify/compare/v0.69.0...v0.70.0) (2024-08-01) - ### Bug Fixes -* **deps:** update dependency nodemailer-mjml to v1.3.6 ([#692](https://github.com/dzangolab/fastify/issues/692)) ([3afea41](https://github.com/dzangolab/fastify/commit/3afea419139efcad8f7aaf61d7934a859a425583)) -* **deps:** update turbo monorepo to v2.0.6 ([#693](https://github.com/dzangolab/fastify/issues/693)) ([4559462](https://github.com/dzangolab/fastify/commit/4559462d77cb1d02216a90cb9b6c4f4656fd9c80)) - +- **deps:** update dependency nodemailer-mjml to v1.3.6 ([#692](https://github.com/dzangolab/fastify/issues/692)) ([3afea41](https://github.com/dzangolab/fastify/commit/3afea419139efcad8f7aaf61d7934a859a425583)) +- **deps:** update turbo monorepo to v2.0.6 ([#693](https://github.com/dzangolab/fastify/issues/693)) ([4559462](https://github.com/dzangolab/fastify/commit/4559462d77cb1d02216a90cb9b6c4f4656fd9c80)) ### Features -* **graphql:** add graphql package ([#708](https://github.com/dzangolab/fastify/issues/708)) ([12e916c](https://github.com/dzangolab/fastify/commit/12e916c27149bc6bfe096a422a6d7f71ae576639)) - - +- **graphql:** add graphql package ([#708](https://github.com/dzangolab/fastify/issues/708)) ([12e916c](https://github.com/dzangolab/fastify/commit/12e916c27149bc6bfe096a422a6d7f71ae576639)) # [0.69.0](https://github.com/dzangolab/fastify/compare/v0.68.3...v0.69.0) (2024-06-24) - ### Bug Fixes -* **user:** fix createNewSession without request response ([#685](https://github.com/dzangolab/fastify/issues/685)) ([bb04ef3](https://github.com/dzangolab/fastify/commit/bb04ef3c58f00160593ab732e2e3df38002e9517)) - +- **user:** fix createNewSession without request response ([#685](https://github.com/dzangolab/fastify/issues/685)) ([bb04ef3](https://github.com/dzangolab/fastify/commit/bb04ef3c58f00160593ab732e2e3df38002e9517)) ### Features -* **user:** add support for grace period for profile validation ([#684](https://github.com/dzangolab/fastify/issues/684)) ([ab25ad2](https://github.com/dzangolab/fastify/commit/ab25ad2167f8e885f10afbd397f739d2f07c1bd8)) - - +- **user:** add support for grace period for profile validation ([#684](https://github.com/dzangolab/fastify/issues/684)) ([ab25ad2](https://github.com/dzangolab/fastify/commit/ab25ad2167f8e885f10afbd397f739d2f07c1bd8)) ## [0.68.3](https://github.com/dzangolab/fastify/compare/v0.68.2...v0.68.3) (2024-06-12) - - ## [0.68.2](https://github.com/dzangolab/fastify/compare/v0.68.1...v0.68.2) (2024-06-07) - - ## [0.68.1](https://github.com/dzangolab/fastify/compare/v0.68.0...v0.68.1) (2024-06-05) - ### Bug Fixes -* **user:** update session after user update ([#675](https://github.com/dzangolab/fastify/issues/675)) ([22d8d2f](https://github.com/dzangolab/fastify/commit/22d8d2f7a8c52374f566567bd8063a032e61d8fd)) - - +- **user:** update session after user update ([#675](https://github.com/dzangolab/fastify/issues/675)) ([22d8d2f](https://github.com/dzangolab/fastify/commit/22d8d2f7a8c52374f566567bd8063a032e61d8fd)) # [0.68.0](https://github.com/dzangolab/fastify/compare/v0.67.2...v0.68.0) (2024-06-05) - ### Features -* **user:** add endpoint for delete invitation ([#673](https://github.com/dzangolab/fastify/issues/673)) ([d9860d6](https://github.com/dzangolab/fastify/commit/d9860d68f69b06fe396cdafc8f19ad05bf4a51e6)) -* **user:** add user object to the fastify request ([#672](https://github.com/dzangolab/fastify/issues/672)) ([e9f141f](https://github.com/dzangolab/fastify/commit/e9f141f36422024e5fbd265f6eaa13110b40918e)) - - +- **user:** add endpoint for delete invitation ([#673](https://github.com/dzangolab/fastify/issues/673)) ([d9860d6](https://github.com/dzangolab/fastify/commit/d9860d68f69b06fe396cdafc8f19ad05bf4a51e6)) +- **user:** add user object to the fastify request ([#672](https://github.com/dzangolab/fastify/issues/672)) ([e9f141f](https://github.com/dzangolab/fastify/commit/e9f141f36422024e5fbd265f6eaa13110b40918e)) ## [0.67.2](https://github.com/dzangolab/fastify/compare/v0.67.1...v0.67.2) (2024-05-30) - ### Bug Fixes -* **user:** update profile validation claim in sesson for me ([#670](https://github.com/dzangolab/fastify/issues/670)) ([422ea43](https://github.com/dzangolab/fastify/commit/422ea437de8cd7eef6b6b0c7d1a35e553ca17408)) - - +- **user:** update profile validation claim in sesson for me ([#670](https://github.com/dzangolab/fastify/issues/670)) ([422ea43](https://github.com/dzangolab/fastify/commit/422ea437de8cd7eef6b6b0c7d1a35e553ca17408)) ## [0.67.1](https://github.com/dzangolab/fastify/compare/v0.67.0...v0.67.1) (2024-05-28) - ### Bug Fixes -* **user:** support multiple key of user in profile validation fields ([#668](https://github.com/dzangolab/fastify/issues/668)) ([80cdad9](https://github.com/dzangolab/fastify/commit/80cdad98da9d6b48984fb26d2b9fe81b1e6ed959)) - - +- **user:** support multiple key of user in profile validation fields ([#668](https://github.com/dzangolab/fastify/issues/668)) ([80cdad9](https://github.com/dzangolab/fastify/commit/80cdad98da9d6b48984fb26d2b9fe81b1e6ed959)) # [0.67.0](https://github.com/dzangolab/fastify/compare/v0.66.0...v0.67.0) (2024-05-28) - ### Features -* **user:** Add profile validation feature ([#664](https://github.com/dzangolab/fastify/issues/664)) ([db229da](https://github.com/dzangolab/fastify/commit/db229da2f53444649e0b5aa3ab8a0e6a65b9d6eb)) - - +- **user:** Add profile validation feature ([#664](https://github.com/dzangolab/fastify/issues/664)) ([db229da](https://github.com/dzangolab/fastify/commit/db229da2f53444649e0b5aa3ab8a0e6a65b9d6eb)) # [0.66.0](https://github.com/dzangolab/fastify/compare/v0.65.5...v0.66.0) (2024-05-17) - ### Features -* **user:** support appId as URI parameter for reset password request ([#660](https://github.com/dzangolab/fastify/issues/660)) ([fffff73](https://github.com/dzangolab/fastify/commit/fffff73d1890a1e3f93bb5e7e569b4e674a0e191)) - - +- **user:** support appId as URI parameter for reset password request ([#660](https://github.com/dzangolab/fastify/issues/660)) ([fffff73](https://github.com/dzangolab/fastify/commit/fffff73d1890a1e3f93bb5e7e569b4e674a0e191)) ## [0.65.5](https://github.com/dzangolab/fastify/compare/v0.65.4...v0.65.5) (2024-05-16) - - ## [0.65.4](https://github.com/dzangolab/fastify/compare/v0.65.3...v0.65.4) (2024-05-15) - - ## [0.65.3](https://github.com/dzangolab/fastify/compare/v0.65.2...v0.65.3) (2024-05-10) - - ## [0.65.2](https://github.com/dzangolab/fastify/compare/v0.65.1...v0.65.2) (2024-05-03) - ### Features -* **mailer:** add mail recipients ([#648](https://github.com/dzangolab/fastify/issues/648)) ([d4bcece](https://github.com/dzangolab/fastify/commit/d4bcecec0904d3a0b94c15a17266360588c5d5b3)) - - +- **mailer:** add mail recipients ([#648](https://github.com/dzangolab/fastify/issues/648)) ([d4bcece](https://github.com/dzangolab/fastify/commit/d4bcecec0904d3a0b94c15a17266360588c5d5b3)) ## [0.65.1](https://github.com/dzangolab/fastify/compare/v0.65.0...v0.65.1) (2024-04-30) - ### Bug Fixes -* **user:** fix create invitation for existing invitation which is invalidated ([#649](https://github.com/dzangolab/fastify/issues/649)) ([e668b85](https://github.com/dzangolab/fastify/commit/e668b852cc37935ce5482475052952ed3f0bb835)) - - +- **user:** fix create invitation for existing invitation which is invalidated ([#649](https://github.com/dzangolab/fastify/issues/649)) ([e668b85](https://github.com/dzangolab/fastify/commit/e668b852cc37935ce5482475052952ed3f0bb835)) # [0.65.0](https://github.com/dzangolab/fastify/compare/v0.64.2...v0.65.0) (2024-04-25) - ### Features -* **user:** enforce session check in database ([#646](https://github.com/dzangolab/fastify/issues/646)) ([bc22242](https://github.com/dzangolab/fastify/commit/bc22242b8e3e4f5f15b5b7591b16f57354ee85d0)) - - +- **user:** enforce session check in database ([#646](https://github.com/dzangolab/fastify/issues/646)) ([bc22242](https://github.com/dzangolab/fastify/commit/bc22242b8e3e4f5f15b5b7591b16f57354ee85d0)) ## [0.64.2](https://github.com/dzangolab/fastify/compare/v0.64.1...v0.64.2) (2024-04-02) - ### Bug Fixes -* **multi-tenant:** fix tenant emailPassword sign in ([#635](https://github.com/dzangolab/fastify/issues/635)) ([76aa036](https://github.com/dzangolab/fastify/commit/76aa0366b9771f4bc5140c23c23b3184c3c7184d)) - - +- **multi-tenant:** fix tenant emailPassword sign in ([#635](https://github.com/dzangolab/fastify/issues/635)) ([76aa036](https://github.com/dzangolab/fastify/commit/76aa0366b9771f4bc5140c23c23b3184c3c7184d)) ## [0.64.1](https://github.com/dzangolab/fastify/compare/v0.64.0...v0.64.1) (2024-03-27) - ### Bug Fixes -* **deps:** update dependency nodemailer to v6.9.9 [security] ([#607](https://github.com/dzangolab/fastify/issues/607)) ([f37d349](https://github.com/dzangolab/fastify/commit/f37d3492cbff5b57f55dbfa5cd88f293b957f225)) -* **multi-tenant:** add 404 status code in response if such tenant does not exits ([#628](https://github.com/dzangolab/fastify/issues/628)) ([10bc5b2](https://github.com/dzangolab/fastify/commit/10bc5b263e80f2bc328c33a76f3067d36c3560d5)) -* **user:** block create role if it already exists ([#629](https://github.com/dzangolab/fastify/issues/629)) ([65cfb29](https://github.com/dzangolab/fastify/commit/65cfb29af0d448a715c53a02fcf903a570c75902)) - - +- **deps:** update dependency nodemailer to v6.9.9 [security] ([#607](https://github.com/dzangolab/fastify/issues/607)) ([f37d349](https://github.com/dzangolab/fastify/commit/f37d3492cbff5b57f55dbfa5cd88f293b957f225)) +- **multi-tenant:** add 404 status code in response if such tenant does not exits ([#628](https://github.com/dzangolab/fastify/issues/628)) ([10bc5b2](https://github.com/dzangolab/fastify/commit/10bc5b263e80f2bc328c33a76f3067d36c3560d5)) +- **user:** block create role if it already exists ([#629](https://github.com/dzangolab/fastify/issues/629)) ([65cfb29](https://github.com/dzangolab/fastify/commit/65cfb29af0d448a715c53a02fcf903a570c75902)) # [0.64.0](https://github.com/dzangolab/fastify/compare/v0.63.0...v0.64.0) (2024-03-23) - ### Features -* **user:** add permissions while creating role ([#622](https://github.com/dzangolab/fastify/issues/622)) ([5023a51](https://github.com/dzangolab/fastify/commit/5023a5151a210b4a6d71b83be53e08c16e2a4cd3)) - - +- **user:** add permissions while creating role ([#622](https://github.com/dzangolab/fastify/issues/622)) ([5023a51](https://github.com/dzangolab/fastify/commit/5023a5151a210b4a6d71b83be53e08c16e2a4cd3)) # [0.63.0](https://github.com/dzangolab/fastify/compare/v0.62.4...v0.63.0) (2024-03-22) - ### Features -* **config:** add multi-stream support for logger config ([#616](https://github.com/dzangolab/fastify/issues/616)) ([d9ebb2e](https://github.com/dzangolab/fastify/commit/d9ebb2efc4a5785d5e2b6519b4decb1014f6f1af)) - - +- **config:** add multi-stream support for logger config ([#616](https://github.com/dzangolab/fastify/issues/616)) ([d9ebb2e](https://github.com/dzangolab/fastify/commit/d9ebb2efc4a5785d5e2b6519b4decb1014f6f1af)) ## [0.62.4](https://github.com/dzangolab/fastify/compare/v0.62.3...v0.62.4) (2024-03-21) - ### Bug Fixes -* update db filter input type ([#624](https://github.com/dzangolab/fastify/issues/624)) ([7a24d4c](https://github.com/dzangolab/fastify/commit/7a24d4c1a1f308fc9d33d0f387d3866b8dac1e05)) - - +- update db filter input type ([#624](https://github.com/dzangolab/fastify/issues/624)) ([7a24d4c](https://github.com/dzangolab/fastify/commit/7a24d4c1a1f308fc9d33d0f387d3866b8dac1e05)) ## [0.62.3](https://github.com/dzangolab/fastify/compare/v0.62.2...v0.62.3) (2024-03-20) - - ## [0.62.2](https://github.com/dzangolab/fastify/compare/v0.62.1...v0.62.2) (2024-03-07) - ### Features -* **multi-tenant:** check reserved slugs and domains before create tenant ([#606](https://github.com/dzangolab/fastify/issues/606)) ([79db810](https://github.com/dzangolab/fastify/commit/79db810285c59d444e59b598714381896d815f30)) - - +- **multi-tenant:** check reserved slugs and domains before create tenant ([#606](https://github.com/dzangolab/fastify/issues/606)) ([79db810](https://github.com/dzangolab/fastify/commit/79db810285c59d444e59b598714381896d815f30)) ## [0.62.1](https://github.com/dzangolab/fastify/compare/v0.62.0...v0.62.1) (2024-02-19) - ### Features -* return host in tenant all request ([#614](https://github.com/dzangolab/fastify/issues/614)) ([058308a](https://github.com/dzangolab/fastify/commit/058308a201615ee4e69a0a8a6f5b351103ae0aee)) -* **user:** make invitations and user service configurable ([#511](https://github.com/dzangolab/fastify/issues/511)) ([3659f26](https://github.com/dzangolab/fastify/commit/3659f26c73007b8c99f391d0b95ddc5e2f9e2ae7)) - - +- return host in tenant all request ([#614](https://github.com/dzangolab/fastify/issues/614)) ([058308a](https://github.com/dzangolab/fastify/commit/058308a201615ee4e69a0a8a6f5b351103ae0aee)) +- **user:** make invitations and user service configurable ([#511](https://github.com/dzangolab/fastify/issues/511)) ([3659f26](https://github.com/dzangolab/fastify/commit/3659f26c73007b8c99f391d0b95ddc5e2f9e2ae7)) # [0.62.0](https://github.com/dzangolab/fastify/compare/v0.61.1...v0.62.0) (2024-02-12) - ### Features -* **user:** add support for custom third party provider ([#608](https://github.com/dzangolab/fastify/issues/608)) ([1a4ae53](https://github.com/dzangolab/fastify/commit/1a4ae53a8d3be37a56b0179a773e5fa77d72306d)) - - +- **user:** add support for custom third party provider ([#608](https://github.com/dzangolab/fastify/issues/608)) ([1a4ae53](https://github.com/dzangolab/fastify/commit/1a4ae53a8d3be37a56b0179a773e5fa77d72306d)) ## [0.61.1](https://github.com/dzangolab/fastify/compare/v0.61.0...v0.61.1) (2024-02-12) - ### Bug Fixes -* remove role check in user enable and disable graphql resolver ([#611](https://github.com/dzangolab/fastify/issues/611)) ([2d84f4b](https://github.com/dzangolab/fastify/commit/2d84f4b13b425b34d91683f7c63d59bfcd38b726)) - - +- remove role check in user enable and disable graphql resolver ([#611](https://github.com/dzangolab/fastify/issues/611)) ([2d84f4b](https://github.com/dzangolab/fastify/commit/2d84f4b13b425b34d91683f7c63d59bfcd38b726)) # [0.61.0](https://github.com/dzangolab/fastify/compare/v0.60.0...v0.61.0) (2024-01-30) - ### Features -* **multi-tenant:** add endpoint to get all tenants ([#604](https://github.com/dzangolab/fastify/issues/604)) ([70ed7bd](https://github.com/dzangolab/fastify/commit/70ed7bd3dec65f88b83332a1f7b331364beecdae)) -* **user:** auto verify first admin user email when email verification is enabled ([#603](https://github.com/dzangolab/fastify/issues/603)) ([e9ccf84](https://github.com/dzangolab/fastify/commit/e9ccf844fb06940f67da41ac87f9712aa428e941)) - - +- **multi-tenant:** add endpoint to get all tenants ([#604](https://github.com/dzangolab/fastify/issues/604)) ([70ed7bd](https://github.com/dzangolab/fastify/commit/70ed7bd3dec65f88b83332a1f7b331364beecdae)) +- **user:** auto verify first admin user email when email verification is enabled ([#603](https://github.com/dzangolab/fastify/issues/603)) ([e9ccf84](https://github.com/dzangolab/fastify/commit/e9ccf844fb06940f67da41ac87f9712aa428e941)) # [0.60.0](https://github.com/dzangolab/fastify/compare/v0.59.0...v0.60.0) (2024-01-26) - ### Features -* **user:** make accept invitation link path configurable ([#601](https://github.com/dzangolab/fastify/issues/601)) ([5d5aa1f](https://github.com/dzangolab/fastify/commit/5d5aa1fbd94aabf2d969a463cda4f750c3753d18)) - - +- **user:** make accept invitation link path configurable ([#601](https://github.com/dzangolab/fastify/issues/601)) ([5d5aa1f](https://github.com/dzangolab/fastify/commit/5d5aa1fbd94aabf2d969a463cda4f750c3753d18)) # [0.59.0](https://github.com/dzangolab/fastify/compare/v0.58.0...v0.59.0) (2024-01-25) - ### Bug Fixes -* **deps:** update dependency zod to v3.22.4 ([#591](https://github.com/dzangolab/fastify/issues/591)) ([b0b6b61](https://github.com/dzangolab/fastify/commit/b0b6b619e0c0bb0ac292d31752ad93c1f9e1fc0b)) -* **user:** fix link in email verification for first admin sign up ([#598](https://github.com/dzangolab/fastify/issues/598)) ([f991df5](https://github.com/dzangolab/fastify/commit/f991df5f076d7526501f83e27c0f04a0446d2b51)) - +- **deps:** update dependency zod to v3.22.4 ([#591](https://github.com/dzangolab/fastify/issues/591)) ([b0b6b61](https://github.com/dzangolab/fastify/commit/b0b6b619e0c0bb0ac292d31752ad93c1f9e1fc0b)) +- **user:** fix link in email verification for first admin sign up ([#598](https://github.com/dzangolab/fastify/issues/598)) ([f991df5](https://github.com/dzangolab/fastify/commit/f991df5f076d7526501f83e27c0f04a0446d2b51)) ### Features -* **multi-tenant:** add tenant owner role on sign up from www app ([#586](https://github.com/dzangolab/fastify/issues/586)) ([49c341d](https://github.com/dzangolab/fastify/commit/49c341d0d474744b93d0581a5f6d19bfbbff940b)) - - +- **multi-tenant:** add tenant owner role on sign up from www app ([#586](https://github.com/dzangolab/fastify/issues/586)) ([49c341d](https://github.com/dzangolab/fastify/commit/49c341d0d474744b93d0581a5f6d19bfbbff940b)) # [0.58.0](https://github.com/dzangolab/fastify/compare/v0.57.1...v0.58.0) (2024-01-16) - ### Bug Fixes -* **deps:** update dependency @types/busboy to v1.5.3 ([#559](https://github.com/dzangolab/fastify/issues/559)) ([da0cf54](https://github.com/dzangolab/fastify/commit/da0cf5454c5c3a18ebbbbadfe0f525573daa4ffa)) -* **deps:** update dependency nodemailer to v6.9.8 ([#589](https://github.com/dzangolab/fastify/issues/589)) ([0dc1a7d](https://github.com/dzangolab/fastify/commit/0dc1a7d8a1e028a816ed1cbbecb08ad1f462f585)) -* **deps:** update dependency uuid to v9.0.1 ([#590](https://github.com/dzangolab/fastify/issues/590)) ([3537316](https://github.com/dzangolab/fastify/commit/35373164f848a407ccd9241b976e1085c268bf02)) - +- **deps:** update dependency @types/busboy to v1.5.3 ([#559](https://github.com/dzangolab/fastify/issues/559)) ([da0cf54](https://github.com/dzangolab/fastify/commit/da0cf5454c5c3a18ebbbbadfe0f525573daa4ffa)) +- **deps:** update dependency nodemailer to v6.9.8 ([#589](https://github.com/dzangolab/fastify/issues/589)) ([0dc1a7d](https://github.com/dzangolab/fastify/commit/0dc1a7d8a1e028a816ed1cbbecb08ad1f462f585)) +- **deps:** update dependency uuid to v9.0.1 ([#590](https://github.com/dzangolab/fastify/issues/590)) ([3537316](https://github.com/dzangolab/fastify/commit/35373164f848a407ccd9241b976e1085c268bf02)) ### Features -* **multi-tenant:** add owner information on creating tenant ([3ca2756](https://github.com/dzangolab/fastify/commit/3ca27560e820bd7953e579ab9195962cb43f630e)) -* **multi-tenant:** As A tenant owner, I can only get tenants or a tenant created by me. ([#588](https://github.com/dzangolab/fastify/issues/588)) ([6c7bebb](https://github.com/dzangolab/fastify/commit/6c7bebbfbc4442c33506900468e46ea0527d6819)) - - +- **multi-tenant:** add owner information on creating tenant ([3ca2756](https://github.com/dzangolab/fastify/commit/3ca27560e820bd7953e579ab9195962cb43f630e)) +- **multi-tenant:** As A tenant owner, I can only get tenants or a tenant created by me. ([#588](https://github.com/dzangolab/fastify/issues/588)) ([6c7bebb](https://github.com/dzangolab/fastify/commit/6c7bebbfbc4442c33506900468e46ea0527d6819)) ## [0.57.1](https://github.com/dzangolab/fastify/compare/v0.57.0...v0.57.1) (2024-01-08) - - # [0.57.0](https://github.com/dzangolab/fastify/compare/v0.56.0...v0.57.0) (2024-01-04) - ### Features -* **user:** add role based access control (hasPermission middleware and directive to protect routes) ([#564](https://github.com/dzangolab/fastify/issues/564)) ([eca8909](https://github.com/dzangolab/fastify/commit/eca8909c8f5d23182531077ed8a9ee2fd5b8c5b6)) -* **multi-tenant:** add tenant controller and resolver ([#574](https://github.com/dzangolab/fastify/issues/574)) ([95bb1f9](https://github.com/dzangolab/fastify/commit/95bb1f96ac1b4a3218047a6aa219eb00dfe67d89)) - - +- **user:** add role based access control (hasPermission middleware and directive to protect routes) ([#564](https://github.com/dzangolab/fastify/issues/564)) ([eca8909](https://github.com/dzangolab/fastify/commit/eca8909c8f5d23182531077ed8a9ee2fd5b8c5b6)) +- **multi-tenant:** add tenant controller and resolver ([#574](https://github.com/dzangolab/fastify/issues/574)) ([95bb1f9](https://github.com/dzangolab/fastify/commit/95bb1f96ac1b4a3218047a6aa219eb00dfe67d89)) # [0.56.0](https://github.com/dzangolab/fastify/compare/v0.55.2...v0.56.0) (2023-12-25) - ### Features -* add payload support in send notification route ([#581](https://github.com/dzangolab/fastify/issues/581)) ([68c3f9a](https://github.com/dzangolab/fastify/commit/68c3f9a3421083188096034b4cdf7af28f418bd6)) -* **user:** fix create invitation issue when default role is not USER ([#565](https://github.com/dzangolab/fastify/issues/565)) ([7260f11](https://github.com/dzangolab/fastify/commit/7260f11c28164044094184f96f386a5259449bc5)) - - +- add payload support in send notification route ([#581](https://github.com/dzangolab/fastify/issues/581)) ([68c3f9a](https://github.com/dzangolab/fastify/commit/68c3f9a3421083188096034b4cdf7af28f418bd6)) +- **user:** fix create invitation issue when default role is not USER ([#565](https://github.com/dzangolab/fastify/issues/565)) ([7260f11](https://github.com/dzangolab/fastify/commit/7260f11c28164044094184f96f386a5259449bc5)) ## [0.55.2](https://github.com/dzangolab/fastify/compare/v0.55.1...v0.55.2) (2023-12-20) - - ## [0.55.1](https://github.com/dzangolab/fastify/compare/v0.55.0...v0.55.1) (2023-12-20) - - # [0.55.0](https://github.com/dzangolab/fastify/compare/v0.54.0...v0.55.0) (2023-12-19) - ### Features -* add remove device route and multi device notification support ([#575](https://github.com/dzangolab/fastify/issues/575)) ([cadb1ca](https://github.com/dzangolab/fastify/commit/cadb1ca389be99f6106f805b87b787bb0b9077bf)) -* **fastify-firebase:** check if app is already initialized before initializing app ([#571](https://github.com/dzangolab/fastify/issues/571)) ([d8ffaad](https://github.com/dzangolab/fastify/commit/d8ffaadc24044946ae14eb55c768a858f19ad3ed)) - - +- add remove device route and multi device notification support ([#575](https://github.com/dzangolab/fastify/issues/575)) ([cadb1ca](https://github.com/dzangolab/fastify/commit/cadb1ca389be99f6106f805b87b787bb0b9077bf)) +- **fastify-firebase:** check if app is already initialized before initializing app ([#571](https://github.com/dzangolab/fastify/issues/571)) ([d8ffaad](https://github.com/dzangolab/fastify/commit/d8ffaadc24044946ae14eb55c768a858f19ad3ed)) # [0.54.0](https://github.com/dzangolab/fastify/compare/v0.53.4...v0.54.0) (2023-12-15) - ### Features -* add fastify-firebase package for firebase admin utilities ([#566](https://github.com/dzangolab/fastify/issues/566)) ([8131906](https://github.com/dzangolab/fastify/commit/8131906083b575606438aa1ff8ea229d62a4e190)) - - +- add fastify-firebase package for firebase admin utilities ([#566](https://github.com/dzangolab/fastify/issues/566)) ([8131906](https://github.com/dzangolab/fastify/commit/8131906083b575606438aa1ff8ea229d62a4e190)) ## [0.53.4](https://github.com/dzangolab/fastify/compare/v0.53.3...v0.53.4) (2023-12-12) - ### Bug Fixes -* **multi-tenant:** send email verification to correct email address on signup ([#568](https://github.com/dzangolab/fastify/issues/568)) ([953a6aa](https://github.com/dzangolab/fastify/commit/953a6aace116beb2672dc76ba93ab68cd30b8851)) - - +- **multi-tenant:** send email verification to correct email address on signup ([#568](https://github.com/dzangolab/fastify/issues/568)) ([953a6aa](https://github.com/dzangolab/fastify/commit/953a6aace116beb2672dc76ba93ab68cd30b8851)) ## [0.53.3](https://github.com/dzangolab/fastify/compare/v0.53.2...v0.53.3) (2023-12-08) - ### Bug Fixes -* **multi-tenant:** session valid on tenant app where user is authenticated. ([a6b2286](https://github.com/dzangolab/fastify/commit/a6b2286af846f68c596c022d012e97024bb19739)) - - +- **multi-tenant:** session valid on tenant app where user is authenticated. ([a6b2286](https://github.com/dzangolab/fastify/commit/a6b2286af846f68c596c022d012e97024bb19739)) ## [0.53.2](https://github.com/dzangolab/fastify/compare/v0.53.1...v0.53.2) (2023-11-27) - ### Bug Fixes -* close pg client after migration ([10e7384](https://github.com/dzangolab/fastify/commit/10e7384d282084f481c19b9ce3a68d8842f264fd)) - - +- close pg client after migration ([10e7384](https://github.com/dzangolab/fastify/commit/10e7384d282084f481c19b9ce3a68d8842f264fd)) ## [0.53.1](https://github.com/dzangolab/fastify/compare/v0.53.0...v0.53.1) (2023-11-24) - ### Bug Fixes -* **slonik:** support ssl for database connection ([#560](https://github.com/dzangolab/fastify/issues/560)) ([73ed3b5](https://github.com/dzangolab/fastify/commit/73ed3b5926f6a581380128006ac348b7a7efb466)) - - +- **slonik:** support ssl for database connection ([#560](https://github.com/dzangolab/fastify/issues/560)) ([73ed3b5](https://github.com/dzangolab/fastify/commit/73ed3b5926f6a581380128006ac348b7a7efb466)) # [0.53.0](https://github.com/dzangolab/fastify/compare/v0.52.1...v0.53.0) (2023-11-20) ### BREAKING CHANGES -* Multi-tenant: Should Register MigrationPlugin from multi-tenant package to run tenant migrations from app. +- Multi-tenant: Should Register MigrationPlugin from multi-tenant package to run tenant migrations from app. Check Readme of @dzangolab/fastify-multi-tenant package. ### Bug Fixes -* **deps:** update dependency pg to v8.11.3 ([#460](https://github.com/dzangolab/fastify/issues/460)) ([e387afb](https://github.com/dzangolab/fastify/commit/e387afb35353fc828d5ff1dc6d3e103bb9a35cea)) - - +- **deps:** update dependency pg to v8.11.3 ([#460](https://github.com/dzangolab/fastify/issues/460)) ([e387afb](https://github.com/dzangolab/fastify/commit/e387afb35353fc828d5ff1dc6d3e103bb9a35cea)) ## [0.52.1](https://github.com/dzangolab/fastify/compare/v0.52.0...v0.52.1) (2023-11-17) ### Bug Fixes -* **deps:** update dependency slonik to v37.2.0 [security] ([#549](https://github.com/dzangolab/fastify/issues/549)) ([0dfab1b](https://github.com/dzangolab/fastify/commit/0dfab1b05d830d307484ea5be712b4c2e89ecb0e)) - +- **deps:** update dependency slonik to v37.2.0 [security] ([#549](https://github.com/dzangolab/fastify/issues/549)) ([0dfab1b](https://github.com/dzangolab/fastify/commit/0dfab1b05d830d307484ea5be712b4c2e89ecb0e)) # [0.52.0](https://github.com/dzangolab/fastify/compare/v0.51.1...v0.52.0) (2023-11-08) - ### Features -* **user:** make supertokens session recipe configurable ([#546](https://github.com/dzangolab/fastify/issues/546)) ([df02111](https://github.com/dzangolab/fastify/commit/df021110c6d93b1f819b661487da4245481d7f52)) - - +- **user:** make supertokens session recipe configurable ([#546](https://github.com/dzangolab/fastify/issues/546)) ([df02111](https://github.com/dzangolab/fastify/commit/df021110c6d93b1f819b661487da4245481d7f52)) ## [0.51.1](https://github.com/dzangolab/fastify/compare/v0.51.0...v0.51.1) (2023-11-07) - ### Features -* **user:** add user enable and disable graphql resolvers ([#545](https://github.com/dzangolab/fastify/issues/545)) ([1b7d0f8](https://github.com/dzangolab/fastify/commit/1b7d0f818d8b1ac0daeb0a7c7d9190d9f091e7a6)) - - +- **user:** add user enable and disable graphql resolvers ([#545](https://github.com/dzangolab/fastify/issues/545)) ([1b7d0f8](https://github.com/dzangolab/fastify/commit/1b7d0f818d8b1ac0daeb0a7c7d9190d9f091e7a6)) # [0.51.0](https://github.com/dzangolab/fastify/compare/v0.50.1...v0.51.0) (2023-11-05) - ### Features -* **user:** add admin routes to enable/disable user ([#535](https://github.com/dzangolab/fastify/issues/535)) ([b1e3252](https://github.com/dzangolab/fastify/commit/b1e3252ea8e5b9ac1f78c51d1f8fe6d3968066ad)) -* **user:** block protected routes to disabled users ([#542](https://github.com/dzangolab/fastify/issues/542)) ([34353d8](https://github.com/dzangolab/fastify/commit/34353d8010aad34cfafd63d14f80367eff1427aa)) -### Bug Fixes - -* **deps:** update dependency zod to v3.22.3 [security] ([#525](https://github.com/dzangolab/fastify/issues/525)) ([aaf3ac7](https://github.com/dzangolab/fastify/commit/aaf3ac7be3c23b05a7ab2f174116481d580e1836)) +- **user:** add admin routes to enable/disable user ([#535](https://github.com/dzangolab/fastify/issues/535)) ([b1e3252](https://github.com/dzangolab/fastify/commit/b1e3252ea8e5b9ac1f78c51d1f8fe6d3968066ad)) +- **user:** block protected routes to disabled users ([#542](https://github.com/dzangolab/fastify/issues/542)) ([34353d8](https://github.com/dzangolab/fastify/commit/34353d8010aad34cfafd63d14f80367eff1427aa)) +### Bug Fixes +- **deps:** update dependency zod to v3.22.3 [security] ([#525](https://github.com/dzangolab/fastify/issues/525)) ([aaf3ac7](https://github.com/dzangolab/fastify/commit/aaf3ac7be3c23b05a7ab2f174116481d580e1836)) ## [0.50.1](https://github.com/dzangolab/fastify/compare/v0.50.0...v0.50.1) (2023-10-31) - ### Features -* require session to verify email ([#531](https://github.com/dzangolab/fastify/issues/531)) ([6f44f40](https://github.com/dzangolab/fastify/commit/6f44f408b515182e32b1d36d3ddae4edd9b5b4d9)) -* **user:** make api and functions configurable of emailVerificationR… ([#533](https://github.com/dzangolab/fastify/issues/533)) ([5efe261](https://github.com/dzangolab/fastify/commit/5efe261f6582b282119ab6566d7692eca56839c4)) - - +- require session to verify email ([#531](https://github.com/dzangolab/fastify/issues/531)) ([6f44f40](https://github.com/dzangolab/fastify/commit/6f44f408b515182e32b1d36d3ddae4edd9b5b4d9)) +- **user:** make api and functions configurable of emailVerificationR… ([#533](https://github.com/dzangolab/fastify/issues/533)) ([5efe261](https://github.com/dzangolab/fastify/commit/5efe261f6582b282119ab6566d7692eca56839c4)) # [0.50.0](https://github.com/dzangolab/fastify/compare/v0.49.0...v0.50.0) (2023-10-09) - ### Features -* **fastify-user:** add apple redirect handler for android login and multi oauth provider support for apple ([#526](https://github.com/dzangolab/fastify/issues/526)) ([d0e54b3](https://github.com/dzangolab/fastify/commit/d0e54b3f0f51313e05069597b656dacd29e5e2dc)) - - +- **fastify-user:** add apple redirect handler for android login and multi oauth provider support for apple ([#526](https://github.com/dzangolab/fastify/issues/526)) ([d0e54b3](https://github.com/dzangolab/fastify/commit/d0e54b3f0f51313e05069597b656dacd29e5e2dc)) # [0.49.0](https://github.com/dzangolab/fastify/compare/v0.48.1...v0.49.0) (2023-10-03) - ### Features -* **slonik:** Run app migrations through migrationPlugin ([#508](https://github.com/dzangolab/fastify/issues/508)) ([905e25a](https://github.com/dzangolab/fastify/commit/905e25aa71739d5c303c7361886f42a70f07624a)) +- **slonik:** Run app migrations through migrationPlugin ([#508](https://github.com/dzangolab/fastify/issues/508)) ([905e25a](https://github.com/dzangolab/fastify/commit/905e25aa71739d5c303c7361886f42a70f07624a)) ### BREAKING CHANGES -* Slonik: Should Register MigrationPlugin from slonik package to run app migrations +- Slonik: Should Register MigrationPlugin from slonik package to run app migrations Check Readme of @dzangolab/fastify-slonik package. - ## [0.48.1](https://github.com/dzangolab/fastify/compare/v0.48.0...v0.48.1) (2023-09-26) - ### Bug Fixes -* export multipart parser plugin ([#520](https://github.com/dzangolab/fastify/issues/520)) ([fd7e833](https://github.com/dzangolab/fastify/commit/fd7e833521d73f1a4a96ef387317f3bdf11d0245)) - - +- export multipart parser plugin ([#520](https://github.com/dzangolab/fastify/issues/520)) ([fd7e833](https://github.com/dzangolab/fastify/commit/fd7e833521d73f1a4a96ef387317f3bdf11d0245)) # [0.48.0](https://github.com/dzangolab/fastify/compare/v0.47.0...v0.48.0) (2023-09-22) - ### Features -* graphql file upload on fastify-s3 ([#509](https://github.com/dzangolab/fastify/issues/509)) ([5d2220f](https://github.com/dzangolab/fastify/commit/5d2220f20feda49bb84e7dd61a305f197547f22b)) - - +- graphql file upload on fastify-s3 ([#509](https://github.com/dzangolab/fastify/issues/509)) ([5d2220f](https://github.com/dzangolab/fastify/commit/5d2220f20feda49bb84e7dd61a305f197547f22b)) # [0.47.0](https://github.com/dzangolab/fastify/compare/v0.46.0...v0.47.0) (2023-09-19) - ### Bug Fixes -* fix typo in filename resolution strategy ([#515](https://github.com/dzangolab/fastify/issues/515)) ([cb2a196](https://github.com/dzangolab/fastify/commit/cb2a196caea66746a8192f4132f1940419e1d49e)) -* update logic filename suffix to check exact filename ([#516](https://github.com/dzangolab/fastify/issues/516)) ([c1bad9a](https://github.com/dzangolab/fastify/commit/c1bad9a5ce7957a3318c1d788ec1c1f50c7d29f0)) - +- fix typo in filename resolution strategy ([#515](https://github.com/dzangolab/fastify/issues/515)) ([cb2a196](https://github.com/dzangolab/fastify/commit/cb2a196caea66746a8192f4132f1940419e1d49e)) +- update logic filename suffix to check exact filename ([#516](https://github.com/dzangolab/fastify/issues/516)) ([c1bad9a](https://github.com/dzangolab/fastify/commit/c1bad9a5ce7957a3318c1d788ec1c1f50c7d29f0)) ### Features -* update file fields on fastify-s3 ([#512](https://github.com/dzangolab/fastify/issues/512)) ([6e280c4](https://github.com/dzangolab/fastify/commit/6e280c4e460ae9fa3f81ca4f5ef11af088732709)) -* **user:** make handlers configurable ([#504](https://github.com/dzangolab/fastify/issues/504)) ([d1e6fb4](https://github.com/dzangolab/fastify/commit/d1e6fb42ec54ab07e691132731eb9fadd5772496)) - - +- update file fields on fastify-s3 ([#512](https://github.com/dzangolab/fastify/issues/512)) ([6e280c4](https://github.com/dzangolab/fastify/commit/6e280c4e460ae9fa3f81ca4f5ef11af088732709)) +- **user:** make handlers configurable ([#504](https://github.com/dzangolab/fastify/issues/504)) ([d1e6fb4](https://github.com/dzangolab/fastify/commit/d1e6fb42ec54ab07e691132731eb9fadd5772496)) # [0.46.0](https://github.com/dzangolab/fastify/compare/v0.45.0...v0.46.0) (2023-09-13) - ### Bug Fixes -* **user:** fix graphql issue when email not verified for public endpoint ([#493](https://github.com/dzangolab/fastify/issues/493)) ([964e2b4](https://github.com/dzangolab/fastify/commit/964e2b4095a8b760cb06ef87f170b23d06ed6b7e)) - +- **user:** fix graphql issue when email not verified for public endpoint ([#493](https://github.com/dzangolab/fastify/issues/493)) ([964e2b4](https://github.com/dzangolab/fastify/commit/964e2b4095a8b760cb06ef87f170b23d06ed6b7e)) ### Features -* add delete file method to file service on fastify-s3 ([#501](https://github.com/dzangolab/fastify/issues/501)) ([070f248](https://github.com/dzangolab/fastify/commit/070f248930f77af553d3539418cf836705ae2384)) - - +- add delete file method to file service on fastify-s3 ([#501](https://github.com/dzangolab/fastify/issues/501)) ([070f248](https://github.com/dzangolab/fastify/commit/070f248930f77af553d3539418cf836705ae2384)) # [0.45.0](https://github.com/dzangolab/fastify/compare/v0.44.0...v0.45.0) (2023-09-12) - ### Features -* add a function to check existing file on s3 bucket ([#496](https://github.com/dzangolab/fastify/issues/496)) ([e6ad3e6](https://github.com/dzangolab/fastify/commit/e6ad3e67368d107cf7ce04ba355ddcf060e5a2d5)) - - +- add a function to check existing file on s3 bucket ([#496](https://github.com/dzangolab/fastify/issues/496)) ([e6ad3e6](https://github.com/dzangolab/fastify/commit/e6ad3e67368d107cf7ce04ba355ddcf060e5a2d5)) # [0.44.0](https://github.com/dzangolab/fastify/compare/v0.43.0...v0.44.0) (2023-09-04) - ### Bug Fixes -* update vite config ([#492](https://github.com/dzangolab/fastify/issues/492)) ([4c87a42](https://github.com/dzangolab/fastify/commit/4c87a42d3846723c100c61f946f65505f59dfe1d)) - +- update vite config ([#492](https://github.com/dzangolab/fastify/issues/492)) ([4c87a42](https://github.com/dzangolab/fastify/commit/4c87a42d3846723c100c61f946f65505f59dfe1d)) ### Features -* **user:** add ability to auto verify email and send verification email on successful signup ([#489](https://github.com/dzangolab/fastify/issues/489)) ([e49490f](https://github.com/dzangolab/fastify/commit/e49490f0822b1804bf5479d639dbe084c4a4f80d)) - - +- **user:** add ability to auto verify email and send verification email on successful signup ([#489](https://github.com/dzangolab/fastify/issues/489)) ([e49490f](https://github.com/dzangolab/fastify/commit/e49490f0822b1804bf5479d639dbe084c4a4f80d)) # [0.43.0](https://github.com/dzangolab/fastify/compare/v0.42.0...v0.43.0) (2023-09-01) - ### Features -* update s3 package config and remove filename from params ([#486](https://github.com/dzangolab/fastify/issues/486)) ([0c076cf](https://github.com/dzangolab/fastify/commit/0c076cf7f6a4990e46f106be3e389eda9e9fff77)) -* **user:** add email verification recipe ([#482](https://github.com/dzangolab/fastify/issues/482)) ([3d24b17](https://github.com/dzangolab/fastify/commit/3d24b178b9377675b1c394c3b17ca3d0c79a66a2)) -* **user:** remove /auth path for email verification for app ([#487](https://github.com/dzangolab/fastify/issues/487)) ([800189b](https://github.com/dzangolab/fastify/commit/800189b962bb6d6694aabd5b0c7f458edb0aec99)) - - +- update s3 package config and remove filename from params ([#486](https://github.com/dzangolab/fastify/issues/486)) ([0c076cf](https://github.com/dzangolab/fastify/commit/0c076cf7f6a4990e46f106be3e389eda9e9fff77)) +- **user:** add email verification recipe ([#482](https://github.com/dzangolab/fastify/issues/482)) ([3d24b17](https://github.com/dzangolab/fastify/commit/3d24b178b9377675b1c394c3b17ca3d0c79a66a2)) +- **user:** remove /auth path for email verification for app ([#487](https://github.com/dzangolab/fastify/issues/487)) ([800189b](https://github.com/dzangolab/fastify/commit/800189b962bb6d6694aabd5b0c7f458edb0aec99)) # [0.42.0](https://github.com/dzangolab/fastify/compare/v0.41.0...v0.42.0) (2023-08-29) - ### Features -* **fastify-s3:** add s3 client to get all operation of aws s3 ([#467](https://github.com/dzangolab/fastify/issues/467)) ([391757e](https://github.com/dzangolab/fastify/commit/391757e3813a5c33204ee79853da9b05337e52bd)) - - +- **fastify-s3:** add s3 client to get all operation of aws s3 ([#467](https://github.com/dzangolab/fastify/issues/467)) ([391757e](https://github.com/dzangolab/fastify/commit/391757e3813a5c33204ee79853da9b05337e52bd)) # [0.41.0](https://github.com/dzangolab/fastify/compare/v0.40.2...v0.41.0) (2023-08-25) ### BREAKING CHANGES -* Only support supertokens CDI version 2.21 and greater +- Only support supertokens CDI version 2.21 and greater + +- This migration is required when upgrading -* This migration is required when upgrading ``` ALTER TABLE st__session_info ADD COLUMN IF NOT EXISTS use_static_key BOOLEAN NOT NULL DEFAULT(false); ALTER TABLE st__session_info ALTER COLUMN use_static_key DROP DEFAULT; ``` + Check this https://github.com/supertokens/supertokens-node/blob/master/CHANGELOG.md#1400---2023-05-04 to get more info on breaking changes related to supertokens. ## [0.40.2](https://github.com/dzangolab/fastify/compare/v0.40.1...v0.40.2) (2023-08-21) - ### Bug Fixes -* remove mailer from fastify request and graphql context ([#474](https://github.com/dzangolab/fastify/issues/474)) ([ebac4a7](https://github.com/dzangolab/fastify/commit/ebac4a784abd8308afa9635c8768f65c404c0a50)) - - +- remove mailer from fastify request and graphql context ([#474](https://github.com/dzangolab/fastify/issues/474)) ([ebac4a7](https://github.com/dzangolab/fastify/commit/ebac4a784abd8308afa9635c8768f65c404c0a50)) ## [0.40.1](https://github.com/dzangolab/fastify/compare/v0.40.0...v0.40.1) (2023-08-18) - ### Bug Fixes -* **user:** handle session errors in graphql context ([#470](https://github.com/dzangolab/fastify/issues/470)) ([cd4cf8c](https://github.com/dzangolab/fastify/commit/cd4cf8cfea1f5a6d5b8e63dd2d8b8a3f9ca8f322)) - - +- **user:** handle session errors in graphql context ([#470](https://github.com/dzangolab/fastify/issues/470)) ([cd4cf8c](https://github.com/dzangolab/fastify/commit/cd4cf8cfea1f5a6d5b8e63dd2d8b8a3f9ca8f322)) # [0.40.0](https://github.com/dzangolab/fastify/compare/v0.39.1...v0.40.0) (2023-08-17) - ### Features -* add plugin on fastify s3 ([#465](https://github.com/dzangolab/fastify/issues/465)) ([d827b0c](https://github.com/dzangolab/fastify/commit/d827b0c3086dd469b05aadef9c3f502b92405ef4)) -* added dzangolab/fastify-s3 package ([#464](https://github.com/dzangolab/fastify/issues/464)) ([f1f1e8c](https://github.com/dzangolab/fastify/commit/f1f1e8c2cc49b86ff79ee69c1bbc36e8299913ae)) -* **fastify-s3:** add a migration on plugin to create files table ([#466](https://github.com/dzangolab/fastify/issues/466)) ([a416eaf](https://github.com/dzangolab/fastify/commit/a416eafdccde1d4b6314a4d5b0b5496006ad8263)) -* **user:** generate invitation link based on app id or request origin ([#446](https://github.com/dzangolab/fastify/issues/446)) ([9824f46](https://github.com/dzangolab/fastify/commit/9824f46282bfdfeb0f826a5258e6f39185fb173a)) - - +- add plugin on fastify s3 ([#465](https://github.com/dzangolab/fastify/issues/465)) ([d827b0c](https://github.com/dzangolab/fastify/commit/d827b0c3086dd469b05aadef9c3f502b92405ef4)) +- added dzangolab/fastify-s3 package ([#464](https://github.com/dzangolab/fastify/issues/464)) ([f1f1e8c](https://github.com/dzangolab/fastify/commit/f1f1e8c2cc49b86ff79ee69c1bbc36e8299913ae)) +- **fastify-s3:** add a migration on plugin to create files table ([#466](https://github.com/dzangolab/fastify/issues/466)) ([a416eaf](https://github.com/dzangolab/fastify/commit/a416eafdccde1d4b6314a4d5b0b5496006ad8263)) +- **user:** generate invitation link based on app id or request origin ([#446](https://github.com/dzangolab/fastify/issues/446)) ([9824f46](https://github.com/dzangolab/fastify/commit/9824f46282bfdfeb0f826a5258e6f39185fb173a)) ## [0.39.1](https://github.com/dzangolab/fastify/compare/v0.39.0...v0.39.1) (2023-08-14) - ### Bug Fixes -* **deps:** update dependency eslint-config-turbo to v1.10.12 ([#436](https://github.com/dzangolab/fastify/issues/436)) ([9ef7b0a](https://github.com/dzangolab/fastify/commit/9ef7b0aa772406370ab643331f642bb00a191957)) -* **deps:** update dependency nodemailer to v6.9.4 ([#437](https://github.com/dzangolab/fastify/issues/437)) ([0737452](https://github.com/dzangolab/fastify/commit/0737452135465b632d210532cf7a8e144d6879bc)) - +- **deps:** update dependency eslint-config-turbo to v1.10.12 ([#436](https://github.com/dzangolab/fastify/issues/436)) ([9ef7b0a](https://github.com/dzangolab/fastify/commit/9ef7b0aa772406370ab643331f642bb00a191957)) +- **deps:** update dependency nodemailer to v6.9.4 ([#437](https://github.com/dzangolab/fastify/issues/437)) ([0737452](https://github.com/dzangolab/fastify/commit/0737452135465b632d210532cf7a8e144d6879bc)) ### Features -* **user:** first admin signup graphql resolver ([#457](https://github.com/dzangolab/fastify/issues/457)) ([aaccbd9](https://github.com/dzangolab/fastify/commit/aaccbd9ad1fbcdc4eec3f5bff5e6ea39c07e69c5)) - - +- **user:** first admin signup graphql resolver ([#457](https://github.com/dzangolab/fastify/issues/457)) ([aaccbd9](https://github.com/dzangolab/fastify/commit/aaccbd9ad1fbcdc4eec3f5bff5e6ea39c07e69c5)) # [0.39.0](https://github.com/dzangolab/fastify/compare/v0.38.0...v0.39.0) (2023-08-11) - ### Features -* **config:** add apps config in apiConfig ([#429](https://github.com/dzangolab/fastify/issues/429)) ([22d7eed](https://github.com/dzangolab/fastify/commit/22d7eedac81d376f710d7cac01fd2ecf299b44bf)) - - +- **config:** add apps config in apiConfig ([#429](https://github.com/dzangolab/fastify/issues/429)) ([22d7eed](https://github.com/dzangolab/fastify/commit/22d7eedac81d376f710d7cac01fd2ecf299b44bf)) # [0.38.0](https://github.com/dzangolab/fastify/compare/v0.37.1...v0.38.0) (2023-08-09) - - ## [0.37.1](https://github.com/dzangolab/fastify/compare/v0.37.0...v0.37.1) (2023-08-07) - - # [0.37.0](https://github.com/dzangolab/fastify/compare/v0.36.2...v0.37.0) (2023-08-02) - ### Bug Fixes -* **slonik:** fix factory getter method in service ([0a4b013](https://github.com/dzangolab/fastify/commit/0a4b0135e890324561df6a744da84922499f62c8)) - +- **slonik:** fix factory getter method in service ([0a4b013](https://github.com/dzangolab/fastify/commit/0a4b0135e890324561df6a744da84922499f62c8)) ### Features -* **user:** add post accept invitation config ([#442](https://github.com/dzangolab/fastify/issues/442)) ([2e8bb39](https://github.com/dzangolab/fastify/commit/2e8bb397bc9ed29f2a3b622a6c632485d78a3714)) -* **user:** add User in invitation list method ([#445](https://github.com/dzangolab/fastify/issues/445)) ([44bd832](https://github.com/dzangolab/fastify/commit/44bd832646ec01759c7ac21f0d05cf229c71bb0f)) -* **user:** graphql endpoints for invitation ([#440](https://github.com/dzangolab/fastify/issues/440)) ([8d50ab9](https://github.com/dzangolab/fastify/commit/8d50ab9e1314708dbe20daab912198ede4d7c9de)) - - +- **user:** add post accept invitation config ([#442](https://github.com/dzangolab/fastify/issues/442)) ([2e8bb39](https://github.com/dzangolab/fastify/commit/2e8bb397bc9ed29f2a3b622a6c632485d78a3714)) +- **user:** add User in invitation list method ([#445](https://github.com/dzangolab/fastify/issues/445)) ([44bd832](https://github.com/dzangolab/fastify/commit/44bd832646ec01759c7ac21f0d05cf229c71bb0f)) +- **user:** graphql endpoints for invitation ([#440](https://github.com/dzangolab/fastify/issues/440)) ([8d50ab9](https://github.com/dzangolab/fastify/commit/8d50ab9e1314708dbe20daab912198ede4d7c9de)) ## [0.36.2](https://github.com/dzangolab/fastify/compare/v0.36.1...v0.36.2) (2023-07-28) - ### Performance Improvements -* **user:** remove invitation token in list handler ([#441](https://github.com/dzangolab/fastify/issues/441)) ([c68cb8d](https://github.com/dzangolab/fastify/commit/c68cb8dbfedf11768e020bac74cccf9a3926a14a)) - - +- **user:** remove invitation token in list handler ([#441](https://github.com/dzangolab/fastify/issues/441)) ([c68cb8d](https://github.com/dzangolab/fastify/commit/c68cb8dbfedf11768e020bac74cccf9a3926a14a)) ## [0.36.1](https://github.com/dzangolab/fastify/compare/v0.36.0...v0.36.1) (2023-07-25) - ### Bug Fixes -* **deps:** update typescript-eslint monorepo to v5.62.0 ([#414](https://github.com/dzangolab/fastify/issues/414)) ([1aa60b1](https://github.com/dzangolab/fastify/commit/1aa60b1623d9ade692a14f47337a1ac209b42698)) - - +- **deps:** update typescript-eslint monorepo to v5.62.0 ([#414](https://github.com/dzangolab/fastify/issues/414)) ([1aa60b1](https://github.com/dzangolab/fastify/commit/1aa60b1623d9ade692a14f47337a1ac209b42698)) # [0.36.0](https://github.com/dzangolab/fastify/compare/v0.35.0...v0.36.0) (2023-07-24) - ### Features -* **user:** revoke invitation ([#426](https://github.com/dzangolab/fastify/issues/426)) ([7d14c60](https://github.com/dzangolab/fastify/commit/7d14c601ede5e67e9c8a5bc71b217dbceb3fa243)) -* **user:** throw error while create invitation if already have valid invitation in database. ([#433](https://github.com/dzangolab/fastify/issues/433)) ([c5caa8a](https://github.com/dzangolab/fastify/commit/c5caa8a773d7507069b4b023a0c051dfedae8e82)) - - +- **user:** revoke invitation ([#426](https://github.com/dzangolab/fastify/issues/426)) ([7d14c60](https://github.com/dzangolab/fastify/commit/7d14c601ede5e67e9c8a5bc71b217dbceb3fa243)) +- **user:** throw error while create invitation if already have valid invitation in database. ([#433](https://github.com/dzangolab/fastify/issues/433)) ([c5caa8a](https://github.com/dzangolab/fastify/commit/c5caa8a773d7507069b4b023a0c051dfedae8e82)) # [0.35.0](https://github.com/dzangolab/fastify/compare/v0.34.0...v0.35.0) (2023-07-21) - ### Features -* **user:** Add get invitation by token endpoint ([#424](https://github.com/dzangolab/fastify/issues/424)) ([2ff38d2](https://github.com/dzangolab/fastify/commit/2ff38d262eb81f65819028494a72d39b647c3cf6)) -* **user:** add list invitatons controller ([#428](https://github.com/dzangolab/fastify/issues/428)) ([2716b29](https://github.com/dzangolab/fastify/commit/2716b2927cadf5b387d6c86d4c447550659f540b)) - - +- **user:** Add get invitation by token endpoint ([#424](https://github.com/dzangolab/fastify/issues/424)) ([2ff38d2](https://github.com/dzangolab/fastify/commit/2ff38d262eb81f65819028494a72d39b647c3cf6)) +- **user:** add list invitatons controller ([#428](https://github.com/dzangolab/fastify/issues/428)) ([2716b29](https://github.com/dzangolab/fastify/commit/2716b2927cadf5b387d6c86d4c447550659f540b)) # [0.34.0](https://github.com/dzangolab/fastify/compare/v0.33.0...v0.34.0) (2023-07-19) - ### Features -* **user:** create invitation ([#423](https://github.com/dzangolab/fastify/issues/423)) ([ed8dcab](https://github.com/dzangolab/fastify/commit/ed8dcabeecea54e6ef4c02d81083df8fd7271db6)) - - +- **user:** create invitation ([#423](https://github.com/dzangolab/fastify/issues/423)) ([ed8dcab](https://github.com/dzangolab/fastify/commit/ed8dcabeecea54e6ef4c02d81083df8fd7271db6)) # [0.33.0](https://github.com/dzangolab/fastify/compare/v0.32.10...v0.33.0) (2023-06-27) - ### Features -* **user:** upgrade supertokens node to 13.6.0 ([#419](https://github.com/dzangolab/fastify/issues/419)) ([c91034a](https://github.com/dzangolab/fastify/commit/c91034adca754baf746ba22132583851e123ce3e)) - - +- **user:** upgrade supertokens node to 13.6.0 ([#419](https://github.com/dzangolab/fastify/issues/419)) ([c91034a](https://github.com/dzangolab/fastify/commit/c91034adca754baf746ba22132583851e123ce3e)) ## [0.32.10](https://github.com/dzangolab/fastify/compare/v0.32.9...v0.32.10) (2023-06-19) - ### Bug Fixes -* **deps:** update dependency eslint-config-turbo to v1.10.3 ([#410](https://github.com/dzangolab/fastify/issues/410)) ([e2a8e2a](https://github.com/dzangolab/fastify/commit/e2a8e2aeb17a3d91a7e06c27d7c58495dfabd784)) -* **deps:** update dependency nodemailer-mjml to v1.2.24 ([#407](https://github.com/dzangolab/fastify/issues/407)) ([acb8499](https://github.com/dzangolab/fastify/commit/acb849982b32f423f210fb3b65190b156093460c)) -* **deps:** update dependency pg to v8.11.0 ([#178](https://github.com/dzangolab/fastify/issues/178)) ([a59d4c0](https://github.com/dzangolab/fastify/commit/a59d4c023fd37c2505a55b5874f70457ed79c861)) -* **deps:** update dependency vue-eslint-parser to v9.3.1 ([#412](https://github.com/dzangolab/fastify/issues/412)) ([e2255b5](https://github.com/dzangolab/fastify/commit/e2255b50cc7521d3b399cd108dff397c40d2f4b4)) -* **deps:** update typescript-eslint monorepo to v5.59.11 ([#370](https://github.com/dzangolab/fastify/issues/370)) ([614a85d](https://github.com/dzangolab/fastify/commit/614a85dc50c513c00e7f068f136edbaa5f2e403d)) - - +- **deps:** update dependency eslint-config-turbo to v1.10.3 ([#410](https://github.com/dzangolab/fastify/issues/410)) ([e2a8e2a](https://github.com/dzangolab/fastify/commit/e2a8e2aeb17a3d91a7e06c27d7c58495dfabd784)) +- **deps:** update dependency nodemailer-mjml to v1.2.24 ([#407](https://github.com/dzangolab/fastify/issues/407)) ([acb8499](https://github.com/dzangolab/fastify/commit/acb849982b32f423f210fb3b65190b156093460c)) +- **deps:** update dependency pg to v8.11.0 ([#178](https://github.com/dzangolab/fastify/issues/178)) ([a59d4c0](https://github.com/dzangolab/fastify/commit/a59d4c023fd37c2505a55b5874f70457ed79c861)) +- **deps:** update dependency vue-eslint-parser to v9.3.1 ([#412](https://github.com/dzangolab/fastify/issues/412)) ([e2255b5](https://github.com/dzangolab/fastify/commit/e2255b50cc7521d3b399cd108dff397c40d2f4b4)) +- **deps:** update typescript-eslint monorepo to v5.59.11 ([#370](https://github.com/dzangolab/fastify/issues/370)) ([614a85d](https://github.com/dzangolab/fastify/commit/614a85dc50c513c00e7f068f136edbaa5f2e403d)) ## [0.32.9](https://github.com/dzangolab/fastify/compare/v0.32.8...v0.32.9) (2023-06-15) - ### Features -* **slonik:** run package migrations ([#374](https://github.com/dzangolab/fastify/issues/374)) ([9e45ff0](https://github.com/dzangolab/fastify/commit/9e45ff0381765a6aa851b7486b2f754b9ec180d4)) -* **user:** update user details ([#338](https://github.com/dzangolab/fastify/issues/338)) ([eabf0cd](https://github.com/dzangolab/fastify/commit/eabf0cdb4bc2272e5867f70a0daad13410550f05)) - - +- **slonik:** run package migrations ([#374](https://github.com/dzangolab/fastify/issues/374)) ([9e45ff0](https://github.com/dzangolab/fastify/commit/9e45ff0381765a6aa851b7486b2f754b9ec180d4)) +- **user:** update user details ([#338](https://github.com/dzangolab/fastify/issues/338)) ([eabf0cd](https://github.com/dzangolab/fastify/commit/eabf0cdb4bc2272e5867f70a0daad13410550f05)) ## [0.32.8](https://github.com/dzangolab/fastify/compare/v0.32.7...v0.32.8) (2023-06-07) - ### Bug Fixes -* **deps:** update dependency nodemailer to v6.9.3 ([#378](https://github.com/dzangolab/fastify/issues/378)) ([ef911d6](https://github.com/dzangolab/fastify/commit/ef911d6b28857f217a43693f0ede9f30acffb5cb)) -* **deps:** update dependency nodemailer-mjml to v1.2.22 ([#379](https://github.com/dzangolab/fastify/issues/379)) ([fc809bb](https://github.com/dzangolab/fastify/commit/fc809bb8c8c87f890d10a164c6cb49998fb0a42a)) - +- **deps:** update dependency nodemailer to v6.9.3 ([#378](https://github.com/dzangolab/fastify/issues/378)) ([ef911d6](https://github.com/dzangolab/fastify/commit/ef911d6b28857f217a43693f0ede9f30acffb5cb)) +- **deps:** update dependency nodemailer-mjml to v1.2.22 ([#379](https://github.com/dzangolab/fastify/issues/379)) ([fc809bb](https://github.com/dzangolab/fastify/commit/fc809bb8c8c87f890d10a164c6cb49998fb0a42a)) ### Features -* **user:** send reset password success email to user ([#400](https://github.com/dzangolab/fastify/issues/400)) ([4b1d7d7](https://github.com/dzangolab/fastify/commit/4b1d7d7b2acd265bbc1532550933aa5685105377)) - - +- **user:** send reset password success email to user ([#400](https://github.com/dzangolab/fastify/issues/400)) ([4b1d7d7](https://github.com/dzangolab/fastify/commit/4b1d7d7b2acd265bbc1532550933aa5685105377)) ## [0.32.7](https://github.com/dzangolab/fastify/compare/v0.32.6...v0.32.7) (2023-06-01) - ### Features -* **user:** add role validation for sign up ([#391](https://github.com/dzangolab/fastify/issues/391)) ([dbb15db](https://github.com/dzangolab/fastify/commit/dbb15db4e24fac26abb142a19e4dc2690bc1d080)) - - +- **user:** add role validation for sign up ([#391](https://github.com/dzangolab/fastify/issues/391)) ([dbb15db](https://github.com/dzangolab/fastify/commit/dbb15db4e24fac26abb142a19e4dc2690bc1d080)) ## [0.32.6](https://github.com/dzangolab/fastify/compare/v0.32.5...v0.32.6) (2023-05-31) - - ## [0.32.5](https://github.com/dzangolab/fastify/compare/v0.32.4...v0.32.5) (2023-05-26) - ### Bug Fixes -* send email asyncronously ([206c547](https://github.com/dzangolab/fastify/commit/206c547daaf5c7ef77a229a5218f8ba6d4e3ab14)) - +- send email asyncronously ([206c547](https://github.com/dzangolab/fastify/commit/206c547daaf5c7ef77a229a5218f8ba6d4e3ab14)) ### Features -* **user:** add roles in users endpoint ([#389](https://github.com/dzangolab/fastify/issues/389)) ([1fe8648](https://github.com/dzangolab/fastify/commit/1fe8648b205800ad678410dc1030fd25ce22de92)) -* **user:** export email and password validation from user package ([#392](https://github.com/dzangolab/fastify/issues/392)) ([5c9610f](https://github.com/dzangolab/fastify/commit/5c9610fe3cc8d9de3a48f2e89ee4fa8206f356cb)) - - +- **user:** add roles in users endpoint ([#389](https://github.com/dzangolab/fastify/issues/389)) ([1fe8648](https://github.com/dzangolab/fastify/commit/1fe8648b205800ad678410dc1030fd25ce22de92)) +- **user:** export email and password validation from user package ([#392](https://github.com/dzangolab/fastify/issues/392)) ([5c9610f](https://github.com/dzangolab/fastify/commit/5c9610fe3cc8d9de3a48f2e89ee4fa8206f356cb)) ## [0.32.4](https://github.com/dzangolab/fastify/compare/v0.32.3...v0.32.4) (2023-05-17) - ### Features -* **slonik:** add support camelCase in sort and filter query ([#386](https://github.com/dzangolab/fastify/issues/386)) ([cb0a228](https://github.com/dzangolab/fastify/commit/cb0a228946764d2f7f527eef20d3880d110c22c8)) - - +- **slonik:** add support camelCase in sort and filter query ([#386](https://github.com/dzangolab/fastify/issues/386)) ([cb0a228](https://github.com/dzangolab/fastify/commit/cb0a228946764d2f7f527eef20d3880d110c22c8)) ## [0.32.3](https://github.com/dzangolab/fastify/compare/v0.32.2...v0.32.3) (2023-05-17) - ### Features -* **slonik:** Support case for IS NULL and IS NOT NULL in FilterInput ([#383](https://github.com/dzangolab/fastify/issues/383)) ([69936ec](https://github.com/dzangolab/fastify/commit/69936eced4aec2d1b38862b8a78e28cc7456066e)) - - +- **slonik:** Support case for IS NULL and IS NOT NULL in FilterInput ([#383](https://github.com/dzangolab/fastify/issues/383)) ([69936ec](https://github.com/dzangolab/fastify/commit/69936eced4aec2d1b38862b8a78e28cc7456066e)) ## [0.32.2](https://github.com/dzangolab/fastify/compare/v0.32.1...v0.32.2) (2023-05-16) - ### Bug Fixes -* **deps:** update dependency eslint-config-turbo to v1.9.4 ([#376](https://github.com/dzangolab/fastify/issues/376)) ([d8e302f](https://github.com/dzangolab/fastify/commit/d8e302feb1c8ff692e939d90badcb371fcd6f48d)) -* **deps:** update dependency eslint-plugin-unicorn to v46.0.1 ([#362](https://github.com/dzangolab/fastify/issues/362)) ([1c00080](https://github.com/dzangolab/fastify/commit/1c000801d2eafa0b0d16deaf57853830ddeda613)) -* **slonik:** fix sorting issue for all and list query ([#377](https://github.com/dzangolab/fastify/issues/377)) ([8443a29](https://github.com/dzangolab/fastify/commit/8443a291b1138666b46b0b516086f1932408f63a)) - - +- **deps:** update dependency eslint-config-turbo to v1.9.4 ([#376](https://github.com/dzangolab/fastify/issues/376)) ([d8e302f](https://github.com/dzangolab/fastify/commit/d8e302feb1c8ff692e939d90badcb371fcd6f48d)) +- **deps:** update dependency eslint-plugin-unicorn to v46.0.1 ([#362](https://github.com/dzangolab/fastify/issues/362)) ([1c00080](https://github.com/dzangolab/fastify/commit/1c000801d2eafa0b0d16deaf57853830ddeda613)) +- **slonik:** fix sorting issue for all and list query ([#377](https://github.com/dzangolab/fastify/issues/377)) ([8443a29](https://github.com/dzangolab/fastify/commit/8443a291b1138666b46b0b516086f1932408f63a)) ## [0.32.1](https://github.com/dzangolab/fastify/compare/v0.32.0...v0.32.1) (2023-05-11) - ### Bug Fixes -* **multi-tenant:** fix change password issue ([#372](https://github.com/dzangolab/fastify/issues/372)) ([41154df](https://github.com/dzangolab/fastify/commit/41154df9ad7480d99cd6f1e87566eb9cbd5dfa7a)) - - +- **multi-tenant:** fix change password issue ([#372](https://github.com/dzangolab/fastify/issues/372)) ([41154df](https://github.com/dzangolab/fastify/commit/41154df9ad7480d99cd6f1e87566eb9cbd5dfa7a)) # [0.32.0](https://github.com/dzangolab/fastify/compare/v0.31.3...v0.32.0) (2023-05-10) - ### Features -* Multi tenant authentication ([#355](https://github.com/dzangolab/fastify/issues/355)) ([9b3f6d7](https://github.com/dzangolab/fastify/commit/9b3f6d745e781edf1051e9febf7dc1e2c9e8758e)) - - +- Multi tenant authentication ([#355](https://github.com/dzangolab/fastify/issues/355)) ([9b3f6d7](https://github.com/dzangolab/fastify/commit/9b3f6d745e781edf1051e9febf7dc1e2c9e8758e)) ## [0.31.3](https://github.com/dzangolab/fastify/compare/v0.31.2...v0.31.3) (2023-05-08) - ### Bug Fixes -* **deps:** update dependency nodemailer-mjml to v1.2.18 ([#353](https://github.com/dzangolab/fastify/issues/353)) ([beceef4](https://github.com/dzangolab/fastify/commit/beceef4f42630cf1262249299c616de5f567150a)) -* **deps:** update typescript-eslint monorepo to v5.59.2 ([#344](https://github.com/dzangolab/fastify/issues/344)) ([ed3bc26](https://github.com/dzangolab/fastify/commit/ed3bc267d41a1cb5e5e1540a950e2d0121a1aba4)) - - +- **deps:** update dependency nodemailer-mjml to v1.2.18 ([#353](https://github.com/dzangolab/fastify/issues/353)) ([beceef4](https://github.com/dzangolab/fastify/commit/beceef4f42630cf1262249299c616de5f567150a)) +- **deps:** update typescript-eslint monorepo to v5.59.2 ([#344](https://github.com/dzangolab/fastify/issues/344)) ([ed3bc26](https://github.com/dzangolab/fastify/commit/ed3bc267d41a1cb5e5e1540a950e2d0121a1aba4)) ## [0.31.2](https://github.com/dzangolab/fastify/compare/v0.31.1...v0.31.2) (2023-05-05) ### Features -* **mailer:** decorate mailer in fastify request and graphql context ([#357](https://github.com/dzangolab/fastify/issues/357)) ([5cf086d](https://github.com/dzangolab/fastify/commit/5cf086d3c2f1c4bfa4bcf73bb7517e976503cc12)) - +- **mailer:** decorate mailer in fastify request and graphql context ([#357](https://github.com/dzangolab/fastify/issues/357)) ([5cf086d](https://github.com/dzangolab/fastify/commit/5cf086d3c2f1c4bfa4bcf73bb7517e976503cc12)) ## [0.31.1](https://github.com/dzangolab/fastify/compare/v0.31.0...v0.31.1) (2023-04-26) - - # [0.31.0](https://github.com/dzangolab/fastify/compare/v0.30.0...v0.31.0) (2023-04-25) - ### Bug Fixes -* **deps:** update dependency eslint-config-turbo to v1.9.3 ([#343](https://github.com/dzangolab/fastify/issues/343)) ([8c09e84](https://github.com/dzangolab/fastify/commit/8c09e84144f862a677cbe5ac7b36e3ef871ea8c4)) - +- **deps:** update dependency eslint-config-turbo to v1.9.3 ([#343](https://github.com/dzangolab/fastify/issues/343)) ([8c09e84](https://github.com/dzangolab/fastify/commit/8c09e84144f862a677cbe5ac7b36e3ef871ea8c4)) ### Features -* change user profile to user ([#349](https://github.com/dzangolab/fastify/issues/349)) ([9a94d99](https://github.com/dzangolab/fastify/commit/9a94d99de681275c30ae39361cd40dc8ebb65195)) +- change user profile to user ([#349](https://github.com/dzangolab/fastify/issues/349)) ([9a94d99](https://github.com/dzangolab/fastify/commit/9a94d99de681275c30ae39361cd40dc8ebb65195)) ### BREAKING CHANGES -* (user): removed profile and roles from signin and signup auth response. -* (user): added signedUpAt and lastLoginAt property to User - - +- (user): removed profile and roles from signin and signup auth response. +- (user): added signedUpAt and lastLoginAt property to User # [0.30.0](https://github.com/dzangolab/fastify/compare/v0.29.0...v0.30.0) (2023-04-13) - ### Bug Fixes -* **deps:** update dependency eslint-config-turbo to v1.9.1 ([#335](https://github.com/dzangolab/fastify/issues/335)) ([37c53d7](https://github.com/dzangolab/fastify/commit/37c53d70531c4a20066badbcf87d49dd8dcce88d)) -* **deps:** update typescript-eslint monorepo to v5.58.0 ([#331](https://github.com/dzangolab/fastify/issues/331)) ([79a0e59](https://github.com/dzangolab/fastify/commit/79a0e598b7190469cc630a6a5d7bb462fc0914a1)) - +- **deps:** update dependency eslint-config-turbo to v1.9.1 ([#335](https://github.com/dzangolab/fastify/issues/335)) ([37c53d7](https://github.com/dzangolab/fastify/commit/37c53d70531c4a20066badbcf87d49dd8dcce88d)) +- **deps:** update typescript-eslint monorepo to v5.58.0 ([#331](https://github.com/dzangolab/fastify/issues/331)) ([79a0e59](https://github.com/dzangolab/fastify/commit/79a0e598b7190469cc630a6a5d7bb462fc0914a1)) ### Features -* **slonik:** remove paginatedList ([#305](https://github.com/dzangolab/fastify/issues/305)) ([8757b53](https://github.com/dzangolab/fastify/commit/8757b535d5f9d442a319129ffd87f960c2107657)) -* **user:** customizable signUpFeature in supertoken's third party email password recipe ([#332](https://github.com/dzangolab/fastify/issues/332)) ([6241374](https://github.com/dzangolab/fastify/commit/62413743e442f3a344be872fa85f8eca885750e6)) - - +- **slonik:** remove paginatedList ([#305](https://github.com/dzangolab/fastify/issues/305)) ([8757b53](https://github.com/dzangolab/fastify/commit/8757b535d5f9d442a319129ffd87f960c2107657)) +- **user:** customizable signUpFeature in supertoken's third party email password recipe ([#332](https://github.com/dzangolab/fastify/issues/332)) ([6241374](https://github.com/dzangolab/fastify/commit/62413743e442f3a344be872fa85f8eca885750e6)) # [0.29.0](https://github.com/dzangolab/fastify/compare/v0.28.0...v0.29.0) (2023-04-12) - ### Bug Fixes -* **deps:** update dependency eslint-config-prettier to v8.8.0 ([#315](https://github.com/dzangolab/fastify/issues/315)) ([5d28a05](https://github.com/dzangolab/fastify/commit/5d28a05fd8184b0b4e4773c2b7061d16693f317e)) -* **deps:** update dependency eslint-config-turbo to v1 ([#321](https://github.com/dzangolab/fastify/issues/321)) ([fedb91f](https://github.com/dzangolab/fastify/commit/fedb91f2333e2ccb16c19dbc26a090a13f1ff1c9)) -* **deps:** update dependency eslint-import-resolver-typescript to v3.5.5 ([#327](https://github.com/dzangolab/fastify/issues/327)) ([f48a187](https://github.com/dzangolab/fastify/commit/f48a187ca133c18aea9cb7ca62e5630891ffdadd)) -* **deps:** update dependency eslint-plugin-unicorn to v46 ([#322](https://github.com/dzangolab/fastify/issues/322)) ([f789f2b](https://github.com/dzangolab/fastify/commit/f789f2b134d445958e40ff81033970a7f8846bce)) -* **deps:** update dependency nodemailer-mjml to v1.2.15 ([#317](https://github.com/dzangolab/fastify/issues/317)) ([5852d42](https://github.com/dzangolab/fastify/commit/5852d426b0a9bcb8df0de16aa205b594c6b00dd7)) - +- **deps:** update dependency eslint-config-prettier to v8.8.0 ([#315](https://github.com/dzangolab/fastify/issues/315)) ([5d28a05](https://github.com/dzangolab/fastify/commit/5d28a05fd8184b0b4e4773c2b7061d16693f317e)) +- **deps:** update dependency eslint-config-turbo to v1 ([#321](https://github.com/dzangolab/fastify/issues/321)) ([fedb91f](https://github.com/dzangolab/fastify/commit/fedb91f2333e2ccb16c19dbc26a090a13f1ff1c9)) +- **deps:** update dependency eslint-import-resolver-typescript to v3.5.5 ([#327](https://github.com/dzangolab/fastify/issues/327)) ([f48a187](https://github.com/dzangolab/fastify/commit/f48a187ca133c18aea9cb7ca62e5630891ffdadd)) +- **deps:** update dependency eslint-plugin-unicorn to v46 ([#322](https://github.com/dzangolab/fastify/issues/322)) ([f789f2b](https://github.com/dzangolab/fastify/commit/f789f2b134d445958e40ff81033970a7f8846bce)) +- **deps:** update dependency nodemailer-mjml to v1.2.15 ([#317](https://github.com/dzangolab/fastify/issues/317)) ([5852d42](https://github.com/dzangolab/fastify/commit/5852d426b0a9bcb8df0de16aa205b594c6b00dd7)) ### Features -* **user:** make third party email password recipe functions/apis customizable from user config ([#316](https://github.com/dzangolab/fastify/issues/316)) ([b5fc939](https://github.com/dzangolab/fastify/commit/b5fc939b1fa9476ddfd2583049c3c9639bfdf783)) - - +- **user:** make third party email password recipe functions/apis customizable from user config ([#316](https://github.com/dzangolab/fastify/issues/316)) ([b5fc939](https://github.com/dzangolab/fastify/commit/b5fc939b1fa9476ddfd2583049c3c9639bfdf783)) # [0.28.0](https://github.com/dzangolab/fastify/compare/v0.27.1...v0.28.0) (2023-04-11) ### BREAKING CHANGES -* **user:** remove user object from session token ([#302](https://github.com/dzangolab/fastify/issues/247)) ([c1c0e7f](https://github.com/dzangolab/fastify/commit/c1c0e7f0e6bec30ad45981161ff3e043c7927fc7)) +- **user:** remove user object from session token ([#302](https://github.com/dzangolab/fastify/issues/247)) ([c1c0e7f](https://github.com/dzangolab/fastify/commit/c1c0e7f0e6bec30ad45981161ff3e043c7927fc7)) ## [0.27.1](https://github.com/dzangolab/fastify/compare/v0.27.0...v0.27.1) (2023-04-05) - - # [0.27.0](https://github.com/dzangolab/fastify/compare/v0.26.3...v0.27.0) (2023-04-04) - ### Bug Fixes -* **deps:** update dependency vue-eslint-parser to v9.1.1 ([#303](https://github.com/dzangolab/fastify/issues/303)) ([9393642](https://github.com/dzangolab/fastify/commit/9393642432a68fb94fda502b243d44979c301ebc)) -* **deps:** update typescript-eslint monorepo to v5.57.1 ([#309](https://github.com/dzangolab/fastify/issues/309)) ([610c03c](https://github.com/dzangolab/fastify/commit/610c03c1af1a0343c98110a64b6baea6c09a4e45)) - +- **deps:** update dependency vue-eslint-parser to v9.1.1 ([#303](https://github.com/dzangolab/fastify/issues/303)) ([9393642](https://github.com/dzangolab/fastify/commit/9393642432a68fb94fda502b243d44979c301ebc)) +- **deps:** update typescript-eslint monorepo to v5.57.1 ([#309](https://github.com/dzangolab/fastify/issues/309)) ([610c03c](https://github.com/dzangolab/fastify/commit/610c03c1af1a0343c98110a64b6baea6c09a4e45)) ### BREAKING CHANGES -* **slonik:** update list method of service class ([#302](https://github.com/dzangolab/fastify/issues/302)) ([8f7f83f](https://github.com/dzangolab/fastify/commit/8f7f83ff2ceef73bbc0dc67eb65616821dca70d2)) - - +- **slonik:** update list method of service class ([#302](https://github.com/dzangolab/fastify/issues/302)) ([8f7f83f](https://github.com/dzangolab/fastify/commit/8f7f83ff2ceef73bbc0dc67eb65616821dca70d2)) ## [0.26.3](https://github.com/dzangolab/fastify/compare/v0.26.2...v0.26.3) (2023-04-03) - ### Bug Fixes -* **deps:** update dependency eslint-import-resolver-typescript to v3.5.4 ([#295](https://github.com/dzangolab/fastify/issues/295)) ([8de8f4b](https://github.com/dzangolab/fastify/commit/8de8f4bad1fe53510afdc96298c1075a9c55a08e)) - - +- **deps:** update dependency eslint-import-resolver-typescript to v3.5.4 ([#295](https://github.com/dzangolab/fastify/issues/295)) ([8de8f4b](https://github.com/dzangolab/fastify/commit/8de8f4bad1fe53510afdc96298c1075a9c55a08e)) ## [0.26.2](https://github.com/dzangolab/fastify/compare/v0.26.1...v0.26.2) (2023-03-30) - ### Bug Fixes -* fix zod validation for getAllSql query ([#294](https://github.com/dzangolab/fastify/issues/294)) ([4d239d0](https://github.com/dzangolab/fastify/commit/4d239d0324386e422c9073997644c46c0cda45d8)) - - +- fix zod validation for getAllSql query ([#294](https://github.com/dzangolab/fastify/issues/294)) ([4d239d0](https://github.com/dzangolab/fastify/commit/4d239d0324386e422c9073997644c46c0cda45d8)) ## [0.26.1](https://github.com/dzangolab/fastify/compare/v0.26.0...v0.26.1) (2023-03-29) - ### Bug Fixes -* **deps:** update dependency eslint-config-turbo to v0.0.10 ([#287](https://github.com/dzangolab/fastify/issues/287)) ([4ce0514](https://github.com/dzangolab/fastify/commit/4ce0514681f1f25cf6921ed027579a45e03dbf2f)) -* **deps:** update dependency html-to-text to v9.0.5 ([#288](https://github.com/dzangolab/fastify/issues/288)) ([878830e](https://github.com/dzangolab/fastify/commit/878830e47422f6aa0e0347270d2edd0d4521ea56)) -* **deps:** update dependency nodemailer-mjml to v1.2.13 ([#196](https://github.com/dzangolab/fastify/issues/196)) ([7692ad4](https://github.com/dzangolab/fastify/commit/7692ad4d07da1cf32ce2f18bece6415adef51020)) -* **deps:** update typescript-eslint monorepo to v5.57.0 ([#216](https://github.com/dzangolab/fastify/issues/216)) ([ac92830](https://github.com/dzangolab/fastify/commit/ac928305abf39c596d91c4d6d8730b34729f1c7f)) -* **user:** return paginated user list on users endpoint ([#283](https://github.com/dzangolab/fastify/issues/283)) ([00954dc](https://github.com/dzangolab/fastify/commit/00954dc681f6a8ecce8f483511967bd3b50d05c8)) - - +- **deps:** update dependency eslint-config-turbo to v0.0.10 ([#287](https://github.com/dzangolab/fastify/issues/287)) ([4ce0514](https://github.com/dzangolab/fastify/commit/4ce0514681f1f25cf6921ed027579a45e03dbf2f)) +- **deps:** update dependency html-to-text to v9.0.5 ([#288](https://github.com/dzangolab/fastify/issues/288)) ([878830e](https://github.com/dzangolab/fastify/commit/878830e47422f6aa0e0347270d2edd0d4521ea56)) +- **deps:** update dependency nodemailer-mjml to v1.2.13 ([#196](https://github.com/dzangolab/fastify/issues/196)) ([7692ad4](https://github.com/dzangolab/fastify/commit/7692ad4d07da1cf32ce2f18bece6415adef51020)) +- **deps:** update typescript-eslint monorepo to v5.57.0 ([#216](https://github.com/dzangolab/fastify/issues/216)) ([ac92830](https://github.com/dzangolab/fastify/commit/ac928305abf39c596d91c4d6d8730b34729f1c7f)) +- **user:** return paginated user list on users endpoint ([#283](https://github.com/dzangolab/fastify/issues/283)) ([00954dc](https://github.com/dzangolab/fastify/commit/00954dc681f6a8ecce8f483511967bd3b50d05c8)) # [0.26.0](https://github.com/dzangolab/fastify/compare/v0.25.3...v0.26.0) (2023-03-28) - ### Features -* minimize fields in user profile ([#276](https://github.com/dzangolab/fastify/issues/276)) ([5e234c0](https://github.com/dzangolab/fastify/commit/5e234c01f1ae00231fe65445994689508e304b2f)) - - +- minimize fields in user profile ([#276](https://github.com/dzangolab/fastify/issues/276)) ([5e234c0](https://github.com/dzangolab/fastify/commit/5e234c01f1ae00231fe65445994689508e304b2f)) ## [0.25.3](https://github.com/dzangolab/fastify/compare/v0.25.2...v0.25.3) (2023-03-24) - ### Bug Fixes -* update password default option and fixed related tests ([041df05](https://github.com/dzangolab/fastify/commit/041df05dee52e7a355fc2e98842220877f1f1e8e)) - - +- update password default option and fixed related tests ([041df05](https://github.com/dzangolab/fastify/commit/041df05dee52e7a355fc2e98842220877f1f1e8e)) ## [0.25.2](https://github.com/dzangolab/fastify/compare/v0.25.1...v0.25.2) (2023-03-24) - ### Bug Fixes -* add user roles to session on signup ([#274](https://github.com/dzangolab/fastify/issues/274)) ([cc3510c](https://github.com/dzangolab/fastify/commit/cc3510c036608746a7244d9a7240f106464376be)) - - +- add user roles to session on signup ([#274](https://github.com/dzangolab/fastify/issues/274)) ([cc3510c](https://github.com/dzangolab/fastify/commit/cc3510c036608746a7244d9a7240f106464376be)) ## [0.25.1](https://github.com/dzangolab/fastify/compare/v0.25.0...v0.25.1) (2023-03-23) - - # [0.25.0](https://github.com/dzangolab/fastify/compare/v0.24.0...v0.25.0) (2023-03-22) - ### Features -* **user:** Email and Password validation customization with config using zod and validator ([#263](https://github.com/dzangolab/fastify/issues/263)) ([41aa997](https://github.com/dzangolab/fastify/commit/41aa997ea3e075fb2192c684e3b09c0d874a4d69)) - - +- **user:** Email and Password validation customization with config using zod and validator ([#263](https://github.com/dzangolab/fastify/issues/263)) ([41aa997](https://github.com/dzangolab/fastify/commit/41aa997ea3e075fb2192c684e3b09c0d874a4d69)) # [0.24.0](https://github.com/dzangolab/fastify/compare/v0.23.0...v0.24.0) (2023-03-20) ### Features -* **slonik:** upgrade slonik to 33.1.0 ([#259](https://github.com/dzangolab/fastify/issues/260)) ([e1cb147](https://github.com/dzangolab/fastify/commit/e1cb14716f819d2cd3007df2409c947d83842cce)) +- **slonik:** upgrade slonik to 33.1.0 ([#259](https://github.com/dzangolab/fastify/issues/260)) ([e1cb147](https://github.com/dzangolab/fastify/commit/e1cb14716f819d2cd3007df2409c947d83842cce)) # [0.23.0](https://github.com/dzangolab/fastify/compare/v0.22.1...v0.23.0) (2023-03-17) - ### Features -* **mercurius:** upgrade mercurius to 12.0.([#259](https://github.com/dzangolab/fastify/issues/259)) ([b5ae65c](https://github.com/dzangolab/fastify/commit/b5ae65c3203861fce983153f306a91d864a07489)) - - +- **mercurius:** upgrade mercurius to 12.0.([#259](https://github.com/dzangolab/fastify/issues/259)) ([b5ae65c](https://github.com/dzangolab/fastify/commit/b5ae65c3203861fce983153f306a91d864a07489)) ## [0.22.1](https://github.com/dzangolab/fastify/compare/v0.22.0...v0.22.1) (2023-03-09) - ### Bug Fixes -* **multi-tenant:** fix getAllWithAliasesSql method ([#254](https://github.com/dzangolab/fastify/issues/254)) ([5a80a25](https://github.com/dzangolab/fastify/commit/5a80a2546a395c79ab33662cbc0bab2bf0156c9c)) - - +- **multi-tenant:** fix getAllWithAliasesSql method ([#254](https://github.com/dzangolab/fastify/issues/254)) ([5a80a25](https://github.com/dzangolab/fastify/commit/5a80a2546a395c79ab33662cbc0bab2bf0156c9c)) # [0.22.0](https://github.com/dzangolab/fastify/compare/v0.21.0...v0.22.0) (2023-03-08) - ### Features -* add role config for user plugin ([#251](https://github.com/dzangolab/fastify/issues/251)) ([40a5402](https://github.com/dzangolab/fastify/commit/40a540201ff22cd3bc25617f11021cad14918df5)) -* **user:** allow "st-auth-mode" header for auth mode on supertokens ([#243](https://github.com/dzangolab/fastify/issues/243)) ([eecfbae](https://github.com/dzangolab/fastify/commit/eecfbae21fe4051958cbc51fe76bd1629bc2233f)) -* **user:** configurable user table name from config ([#250](https://github.com/dzangolab/fastify/issues/250)) ([45e3faa](https://github.com/dzangolab/fastify/commit/45e3faae9c93391078867bc9c1b7de33427fe415)) - - +- add role config for user plugin ([#251](https://github.com/dzangolab/fastify/issues/251)) ([40a5402](https://github.com/dzangolab/fastify/commit/40a540201ff22cd3bc25617f11021cad14918df5)) +- **user:** allow "st-auth-mode" header for auth mode on supertokens ([#243](https://github.com/dzangolab/fastify/issues/243)) ([eecfbae](https://github.com/dzangolab/fastify/commit/eecfbae21fe4051958cbc51fe76bd1629bc2233f)) +- **user:** configurable user table name from config ([#250](https://github.com/dzangolab/fastify/issues/250)) ([45e3faa](https://github.com/dzangolab/fastify/commit/45e3faae9c93391078867bc9c1b7de33427fe415)) # [0.21.0](https://github.com/dzangolab/fastify/compare/v0.20.0...v0.21.0) (2023-02-23) - ### Features -* add current user route ([#237](https://github.com/dzangolab/fastify/issues/237)) ([aa20d20](https://github.com/dzangolab/fastify/commit/aa20d20ab27493beb40534aab52c9cc06f490ba9)) -* **multi-tenant:** multi-tenant tenant-graphql-context ([#239](https://github.com/dzangolab/fastify/issues/239)) ([551f244](https://github.com/dzangolab/fastify/commit/551f24450c06eaaa5ee69edb11a36789986d3b5e)) - - +- add current user route ([#237](https://github.com/dzangolab/fastify/issues/237)) ([aa20d20](https://github.com/dzangolab/fastify/commit/aa20d20ab27493beb40534aab52c9cc06f490ba9)) +- **multi-tenant:** multi-tenant tenant-graphql-context ([#239](https://github.com/dzangolab/fastify/issues/239)) ([551f244](https://github.com/dzangolab/fastify/commit/551f24450c06eaaa5ee69edb11a36789986d3b5e)) # [0.20.0](https://github.com/dzangolab/fastify/compare/v0.19.0...v0.20.0) (2023-02-21) - ### Features -* skip tenant migration if migration path does not exists([#236](https://github.com/dzangolab/fastify/issues/236)) ([62e2f1a](https://github.com/dzangolab/fastify/commit/62e2f1a7b61001b408575d5346f0766986311895)) - - +- skip tenant migration if migration path does not exists([#236](https://github.com/dzangolab/fastify/issues/236)) ([62e2f1a](https://github.com/dzangolab/fastify/commit/62e2f1a7b61001b408575d5346f0766986311895)) # [0.19.0](https://github.com/dzangolab/fastify/compare/v0.18.3...v0.19.0) (2023-02-17) - ### Features -* add support in buildContext for updating context based on augmentation from other plugins ([#173](https://github.com/dzangolab/fastify/issues/173)) ([5e013d2](https://github.com/dzangolab/fastify/commit/5e013d2c0b16009096035f5d5460dd3972805859)) -* **slonik:** add createDatabase module ([#233](https://github.com/dzangolab/fastify/issues/233)) ([5f30db3](https://github.com/dzangolab/fastify/commit/5f30db3475ab20e0a1aad98ff5ae4647ec50ef5e)) - - +- add support in buildContext for updating context based on augmentation from other plugins ([#173](https://github.com/dzangolab/fastify/issues/173)) ([5e013d2](https://github.com/dzangolab/fastify/commit/5e013d2c0b16009096035f5d5460dd3972805859)) +- **slonik:** add createDatabase module ([#233](https://github.com/dzangolab/fastify/issues/233)) ([5f30db3](https://github.com/dzangolab/fastify/commit/5f30db3475ab20e0a1aad98ff5ae4647ec50ef5e)) ## [0.18.3](https://github.com/dzangolab/fastify/compare/v0.18.2...v0.18.3) (2023-02-16) - - ## [0.18.2](https://github.com/dzangolab/fastify/compare/v0.18.1...v0.18.2) (2023-02-15) ### Bug Fixes -* **multi-tenent:** fix tenant discovery and getFindByHostnameSql ([#230](https://github.com/dzangolab/fastify/issues/230)) ([0aab1bd](https://github.com/dzangolab/fastify/commit/0aab1bd44fa5c4e398437a423cf2dc02b2e904da)) - - +- **multi-tenent:** fix tenant discovery and getFindByHostnameSql ([#230](https://github.com/dzangolab/fastify/issues/230)) ([0aab1bd](https://github.com/dzangolab/fastify/commit/0aab1bd44fa5c4e398437a423cf2dc02b2e904da)) ## [0.18.1](https://github.com/dzangolab/fastify/compare/v0.18.0...v0.18.1) (2023-02-15) - ### Bug Fixes -* **multi-tenent:** fix getAliasedField method in sqlFactory ([#226](https://github.com/dzangolab/fastify/issues/226)) ([5e50ff0](https://github.com/dzangolab/fastify/commit/5e50ff0ac5c8de15487062408193b869f9faac14)) - - +- **multi-tenent:** fix getAliasedField method in sqlFactory ([#226](https://github.com/dzangolab/fastify/issues/226)) ([5e50ff0](https://github.com/dzangolab/fastify/commit/5e50ff0ac5c8de15487062408193b869f9faac14)) # [0.18.0](https://github.com/dzangolab/fastify/compare/v0.17.1...v0.18.0) (2023-02-14) - ### Features -* add tests for mailer plugin ([#222](https://github.com/dzangolab/fastify/issues/222)) ([984543d](https://github.com/dzangolab/fastify/commit/984543d648b62e515c1e3df114685245ed44dbee)) - - +- add tests for mailer plugin ([#222](https://github.com/dzangolab/fastify/issues/222)) ([984543d](https://github.com/dzangolab/fastify/commit/984543d648b62e515c1e3df114685245ed44dbee)) ## [0.17.1](https://github.com/dzangolab/fastify/compare/v0.17.0...v0.17.1) (2023-02-07) - - # [0.17.0](https://github.com/dzangolab/fastify/compare/v0.16.0...v0.17.0) (2023-02-05) - - # [0.16.0](https://github.com/dzangolab/fastify/compare/v0.15.2...v0.16.0) (2023-02-05) - - ## [0.15.2](https://github.com/dzangolab/fastify/compare/v0.15.1...v0.15.2) (2023-02-05) - ### Bug Fixes -* **deps:** update typescript-eslint monorepo to v5.50.0 ([#198](https://github.com/dzangolab/fastify/issues/198)) ([7fa5e20](https://github.com/dzangolab/fastify/commit/7fa5e2018f32ba814018046c5630c7d20cfe239f)) - - +- **deps:** update typescript-eslint monorepo to v5.50.0 ([#198](https://github.com/dzangolab/fastify/issues/198)) ([7fa5e20](https://github.com/dzangolab/fastify/commit/7fa5e2018f32ba814018046c5630c7d20cfe239f)) ## [0.15.1](https://github.com/dzangolab/fastify/compare/v0.15.0...v0.15.1) (2023-02-01) - - # [0.15.0](https://github.com/dzangolab/fastify/compare/v0.14.1...v0.15.0) (2023-01-29) - -* Config/tests (#185) ([c924ad9](https://github.com/dzangolab/fastify/commit/c924ad9b1644a4742c3912d395756b1f3dc25a37)), closes [#185](https://github.com/dzangolab/fastify/issues/185) -* Slonik/interceptor/camelize result (#184) ([c42649d](https://github.com/dzangolab/fastify/commit/c42649d55b3900fd9d6b0a92c952f97d65905641)), closes [#184](https://github.com/dzangolab/fastify/issues/184) - +- Config/tests (#185) ([c924ad9](https://github.com/dzangolab/fastify/commit/c924ad9b1644a4742c3912d395756b1f3dc25a37)), closes [#185](https://github.com/dzangolab/fastify/issues/185) +- Slonik/interceptor/camelize result (#184) ([c42649d](https://github.com/dzangolab/fastify/commit/c42649d55b3900fd9d6b0a92c952f97d65905641)), closes [#184](https://github.com/dzangolab/fastify/issues/184) ### BREAKING CHANGES -* SqlFactory arguments have changed. - -* fix(multi-tenant): update service factory - -* chore(slonik): cleanup configuration +- SqlFactory arguments have changed. -* chore(config): cleanup tsconfig -* SqlFactory arguments have changed. +- fix(multi-tenant): update service factory -* fix(multi-tenant): update service factory +- chore(slonik): cleanup configuration -* chore(slonik): cleanup configuration +- chore(config): cleanup tsconfig +- SqlFactory arguments have changed. +- fix(multi-tenant): update service factory +- chore(slonik): cleanup configuration ## [0.14.1](https://github.com/dzangolab/fastify/compare/v0.14.0...v0.14.1) (2023-01-28) - ### Bug Fixes -* **slonik:** fix config.clientConfiguration ([6ae19b5](https://github.com/dzangolab/fastify/commit/6ae19b5adabc1f3fe34051137ae16db04e5a3ae7)) - - +- **slonik:** fix config.clientConfiguration ([6ae19b5](https://github.com/dzangolab/fastify/commit/6ae19b5adabc1f3fe34051137ae16db04e5a3ae7)) # [0.14.0](https://github.com/dzangolab/fastify/compare/v0.13.0...v0.14.0) (2023-01-28) - ### Bug Fixes -* **deps:** update dependency nodemailer to v6.9.1 ([#157](https://github.com/dzangolab/fastify/issues/157)) ([79b981d](https://github.com/dzangolab/fastify/commit/79b981d55ad0ddf329d4e6725b14141193e975b9)) -* **deps:** update dependency nodemailer-mjml to v1.2.4 ([#172](https://github.com/dzangolab/fastify/issues/172)) ([4ae3d0f](https://github.com/dzangolab/fastify/commit/4ae3d0fab6550587001c252038a8d959ccec3f4b)) -* **multi-tenant:** slonik.migrations may be undefined ([7df67e5](https://github.com/dzangolab/fastify/commit/7df67e502f52de887f0ebd21112b60745ef55f2e)) - +- **deps:** update dependency nodemailer to v6.9.1 ([#157](https://github.com/dzangolab/fastify/issues/157)) ([79b981d](https://github.com/dzangolab/fastify/commit/79b981d55ad0ddf329d4e6725b14141193e975b9)) +- **deps:** update dependency nodemailer-mjml to v1.2.4 ([#172](https://github.com/dzangolab/fastify/issues/172)) ([4ae3d0f](https://github.com/dzangolab/fastify/commit/4ae3d0fab6550587001c252038a8d959ccec3f4b)) +- **multi-tenant:** slonik.migrations may be undefined ([7df67e5](https://github.com/dzangolab/fastify/commit/7df67e502f52de887f0ebd21112b60745ef55f2e)) ### Features -* **slonik:** add default migrations path "migrations" ([#179](https://github.com/dzangolab/fastify/issues/179)) ([7f67036](https://github.com/dzangolab/fastify/commit/7f67036d9b2f89307ec8c4615ed920d06fe4cec1)) - - +- **slonik:** add default migrations path "migrations" ([#179](https://github.com/dzangolab/fastify/issues/179)) ([7f67036](https://github.com/dzangolab/fastify/commit/7f67036d9b2f89307ec8c4615ed920d06fe4cec1)) # [0.14.0](https://github.com/dzangolab/fastify/compare/v0.13.0...v0.14.0) (2023-01-28) - ### Bug Fixes -* **deps:** update dependency nodemailer to v6.9.1 ([#157](https://github.com/dzangolab/fastify/issues/157)) ([79b981d](https://github.com/dzangolab/fastify/commit/79b981d55ad0ddf329d4e6725b14141193e975b9)) -* **deps:** update dependency nodemailer-mjml to v1.2.4 ([#172](https://github.com/dzangolab/fastify/issues/172)) ([4ae3d0f](https://github.com/dzangolab/fastify/commit/4ae3d0fab6550587001c252038a8d959ccec3f4b)) - +- **deps:** update dependency nodemailer to v6.9.1 ([#157](https://github.com/dzangolab/fastify/issues/157)) ([79b981d](https://github.com/dzangolab/fastify/commit/79b981d55ad0ddf329d4e6725b14141193e975b9)) +- **deps:** update dependency nodemailer-mjml to v1.2.4 ([#172](https://github.com/dzangolab/fastify/issues/172)) ([4ae3d0f](https://github.com/dzangolab/fastify/commit/4ae3d0fab6550587001c252038a8d959ccec3f4b)) ### Features -* **slonik:** add default migrations path "migrations" ([#179](https://github.com/dzangolab/fastify/issues/179)) ([7f67036](https://github.com/dzangolab/fastify/commit/7f67036d9b2f89307ec8c4615ed920d06fe4cec1)) - - +- **slonik:** add default migrations path "migrations" ([#179](https://github.com/dzangolab/fastify/issues/179)) ([7f67036](https://github.com/dzangolab/fastify/commit/7f67036d9b2f89307ec8c4615ed920d06fe4cec1)) # [0.13.0](https://github.com/dzangolab/fastify/compare/v0.12.3...v0.13.0) (2023-01-26) - ### Bug Fixes -* **deps:** update dependency eslint-plugin-import to v2.27.5 ([#167](https://github.com/dzangolab/fastify/issues/167)) ([b010b3a](https://github.com/dzangolab/fastify/commit/b010b3a62b963462fff578d52f5a5e6ac7e8d227)) -* **deps:** update dependency nodemailer-mjml to v1.2.3 ([#168](https://github.com/dzangolab/fastify/issues/168)) ([93c7614](https://github.com/dzangolab/fastify/commit/93c7614794caca9e9d14d06e161abd5fee96fd55)) -* **deps:** update typescript-eslint monorepo to v5.49.0 ([#161](https://github.com/dzangolab/fastify/issues/161)) ([7cef927](https://github.com/dzangolab/fastify/commit/7cef927e36f4635aa7241549ecf9486687e673bc)) - +- **deps:** update dependency eslint-plugin-import to v2.27.5 ([#167](https://github.com/dzangolab/fastify/issues/167)) ([b010b3a](https://github.com/dzangolab/fastify/commit/b010b3a62b963462fff578d52f5a5e6ac7e8d227)) +- **deps:** update dependency nodemailer-mjml to v1.2.3 ([#168](https://github.com/dzangolab/fastify/issues/168)) ([93c7614](https://github.com/dzangolab/fastify/commit/93c7614794caca9e9d14d06e161abd5fee96fd55)) +- **deps:** update typescript-eslint monorepo to v5.49.0 ([#161](https://github.com/dzangolab/fastify/issues/161)) ([7cef927](https://github.com/dzangolab/fastify/commit/7cef927e36f4635aa7241549ecf9486687e673bc)) ### Features -* **slonik:** schema support for sql queries ([#148](https://github.com/dzangolab/fastify/issues/148)) ([67b52fd](https://github.com/dzangolab/fastify/commit/67b52fd3ba3cf10f9c83746baf755645abd7b219)) - - +- **slonik:** schema support for sql queries ([#148](https://github.com/dzangolab/fastify/issues/148)) ([67b52fd](https://github.com/dzangolab/fastify/commit/67b52fd3ba3cf10f9c83746baf755645abd7b219)) ## [0.12.3](https://github.com/dzangolab/fastify/compare/v0.12.2...v0.12.3) (2023-01-18) - ### Bug Fixes -* **slonik:** fix code style ([e7bc58c](https://github.com/dzangolab/fastify/commit/e7bc58c1f493d720042a1df938e7125995b3f989)) -* **slonik:** fix code style ([9a7d792](https://github.com/dzangolab/fastify/commit/9a7d792e68543c6ecca1337e1eeb5e4809306618)) - - +- **slonik:** fix code style ([e7bc58c](https://github.com/dzangolab/fastify/commit/e7bc58c1f493d720042a1df938e7125995b3f989)) +- **slonik:** fix code style ([9a7d792](https://github.com/dzangolab/fastify/commit/9a7d792e68543c6ecca1337e1eeb5e4809306618)) ## [0.12.2](https://github.com/dzangolab/fastify/compare/v0.12.1...v0.12.2) (2023-01-13) - ### Bug Fixes -* **deps:** update dependency eslint-import-resolver-typescript to v3.5.3 ([#147](https://github.com/dzangolab/fastify/issues/147)) ([7cf223a](https://github.com/dzangolab/fastify/commit/7cf223a41c31d16bdd711ea4d87f6bb1bb4d793e)) -* **deps:** update dependency eslint-plugin-import to v2.27.4 ([#154](https://github.com/dzangolab/fastify/issues/154)) ([6f57d1f](https://github.com/dzangolab/fastify/commit/6f57d1f75233c2ddb12cefdc70e5477fc4685132)) -* **deps:** update typescript-eslint monorepo to v5.48.1 ([#143](https://github.com/dzangolab/fastify/issues/143)) ([44dbbf7](https://github.com/dzangolab/fastify/commit/44dbbf737d4380d5ee64e5d582507646384b570a)) -* **slonik:** make minor fixes to slonik package ([#153](https://github.com/dzangolab/fastify/issues/153)) ([1384df6](https://github.com/dzangolab/fastify/commit/1384df6727c5367a4d9b6205252a749df8ff5aba)) - - +- **deps:** update dependency eslint-import-resolver-typescript to v3.5.3 ([#147](https://github.com/dzangolab/fastify/issues/147)) ([7cf223a](https://github.com/dzangolab/fastify/commit/7cf223a41c31d16bdd711ea4d87f6bb1bb4d793e)) +- **deps:** update dependency eslint-plugin-import to v2.27.4 ([#154](https://github.com/dzangolab/fastify/issues/154)) ([6f57d1f](https://github.com/dzangolab/fastify/commit/6f57d1f75233c2ddb12cefdc70e5477fc4685132)) +- **deps:** update typescript-eslint monorepo to v5.48.1 ([#143](https://github.com/dzangolab/fastify/issues/143)) ([44dbbf7](https://github.com/dzangolab/fastify/commit/44dbbf737d4380d5ee64e5d582507646384b570a)) +- **slonik:** make minor fixes to slonik package ([#153](https://github.com/dzangolab/fastify/issues/153)) ([1384df6](https://github.com/dzangolab/fastify/commit/1384df6727c5367a4d9b6205252a749df8ff5aba)) ## [0.12.1](https://github.com/dzangolab/fastify/compare/v0.12.0...v0.12.1) (2023-01-12) - ### Bug Fixes -* **deps:** update dependency eslint-config-prettier to v8.6.0 ([#131](https://github.com/dzangolab/fastify/issues/131)) ([dcf9ea5](https://github.com/dzangolab/fastify/commit/dcf9ea571e6bf9f49834afeb69a7c9ccafa7a995)) -* **deps:** update dependency nodemailer-mjml to v1.2.2 ([#138](https://github.com/dzangolab/fastify/issues/138)) ([338379c](https://github.com/dzangolab/fastify/commit/338379cac35d137db7c1334c67397b9db4ebb09f)) -* **deps:** update typescript-eslint monorepo to v5.48.0 ([#130](https://github.com/dzangolab/fastify/issues/130)) ([6c4ee5d](https://github.com/dzangolab/fastify/commit/6c4ee5d17cf47a7b3570b747429f311cc5eeff35)) - +- **deps:** update dependency eslint-config-prettier to v8.6.0 ([#131](https://github.com/dzangolab/fastify/issues/131)) ([dcf9ea5](https://github.com/dzangolab/fastify/commit/dcf9ea571e6bf9f49834afeb69a7c9ccafa7a995)) +- **deps:** update dependency nodemailer-mjml to v1.2.2 ([#138](https://github.com/dzangolab/fastify/issues/138)) ([338379c](https://github.com/dzangolab/fastify/commit/338379cac35d137db7c1334c67397b9db4ebb09f)) +- **deps:** update typescript-eslint monorepo to v5.48.0 ([#130](https://github.com/dzangolab/fastify/issues/130)) ([6c4ee5d](https://github.com/dzangolab/fastify/commit/6c4ee5d17cf47a7b3570b747429f311cc5eeff35)) ### Performance Improvements -* **fastify-mailer:** Add support for template data from config ([#135](https://github.com/dzangolab/fastify/issues/135)) ([1b442d0](https://github.com/dzangolab/fastify/commit/1b442d0834fca2df097b4ad836e0abbf4a0914a5)) - - +- **fastify-mailer:** Add support for template data from config ([#135](https://github.com/dzangolab/fastify/issues/135)) ([1b442d0](https://github.com/dzangolab/fastify/commit/1b442d0834fca2df097b4ad836e0abbf4a0914a5)) ## [0.12.2](https://github.com/dzangolab/fastify/compare/v0.12.1...v0.12.2) (2023-01-13) - ### Bug Fixes -* **deps:** update dependency eslint-import-resolver-typescript to v3.5.3 ([#147](https://github.com/dzangolab/fastify/issues/147)) ([7cf223a](https://github.com/dzangolab/fastify/commit/7cf223a41c31d16bdd711ea4d87f6bb1bb4d793e)) -* **deps:** update dependency eslint-plugin-import to v2.27.4 ([#154](https://github.com/dzangolab/fastify/issues/154)) ([6f57d1f](https://github.com/dzangolab/fastify/commit/6f57d1f75233c2ddb12cefdc70e5477fc4685132)) -* **deps:** update typescript-eslint monorepo to v5.48.1 ([#143](https://github.com/dzangolab/fastify/issues/143)) ([44dbbf7](https://github.com/dzangolab/fastify/commit/44dbbf737d4380d5ee64e5d582507646384b570a)) -* **slonik:** make minor fixes to slonik package ([#153](https://github.com/dzangolab/fastify/issues/153)) ([1384df6](https://github.com/dzangolab/fastify/commit/1384df6727c5367a4d9b6205252a749df8ff5aba)) - - +- **deps:** update dependency eslint-import-resolver-typescript to v3.5.3 ([#147](https://github.com/dzangolab/fastify/issues/147)) ([7cf223a](https://github.com/dzangolab/fastify/commit/7cf223a41c31d16bdd711ea4d87f6bb1bb4d793e)) +- **deps:** update dependency eslint-plugin-import to v2.27.4 ([#154](https://github.com/dzangolab/fastify/issues/154)) ([6f57d1f](https://github.com/dzangolab/fastify/commit/6f57d1f75233c2ddb12cefdc70e5477fc4685132)) +- **deps:** update typescript-eslint monorepo to v5.48.1 ([#143](https://github.com/dzangolab/fastify/issues/143)) ([44dbbf7](https://github.com/dzangolab/fastify/commit/44dbbf737d4380d5ee64e5d582507646384b570a)) +- **slonik:** make minor fixes to slonik package ([#153](https://github.com/dzangolab/fastify/issues/153)) ([1384df6](https://github.com/dzangolab/fastify/commit/1384df6727c5367a4d9b6205252a749df8ff5aba)) ## [0.12.1](https://github.com/dzangolab/fastify/compare/v0.12.0...v0.12.1) (2023-01-12) - ### Bug Fixes -* **deps:** update dependency eslint-config-prettier to v8.6.0 ([#131](https://github.com/dzangolab/fastify/issues/131)) ([dcf9ea5](https://github.com/dzangolab/fastify/commit/dcf9ea571e6bf9f49834afeb69a7c9ccafa7a995)) -* **deps:** update dependency nodemailer-mjml to v1.2.2 ([#138](https://github.com/dzangolab/fastify/issues/138)) ([338379c](https://github.com/dzangolab/fastify/commit/338379cac35d137db7c1334c67397b9db4ebb09f)) -* **deps:** update typescript-eslint monorepo to v5.48.0 ([#130](https://github.com/dzangolab/fastify/issues/130)) ([6c4ee5d](https://github.com/dzangolab/fastify/commit/6c4ee5d17cf47a7b3570b747429f311cc5eeff35)) - +- **deps:** update dependency eslint-config-prettier to v8.6.0 ([#131](https://github.com/dzangolab/fastify/issues/131)) ([dcf9ea5](https://github.com/dzangolab/fastify/commit/dcf9ea571e6bf9f49834afeb69a7c9ccafa7a995)) +- **deps:** update dependency nodemailer-mjml to v1.2.2 ([#138](https://github.com/dzangolab/fastify/issues/138)) ([338379c](https://github.com/dzangolab/fastify/commit/338379cac35d137db7c1334c67397b9db4ebb09f)) +- **deps:** update typescript-eslint monorepo to v5.48.0 ([#130](https://github.com/dzangolab/fastify/issues/130)) ([6c4ee5d](https://github.com/dzangolab/fastify/commit/6c4ee5d17cf47a7b3570b747429f311cc5eeff35)) ### Performance Improvements -* **fastify-mailer:** Add support for template data from config ([#135](https://github.com/dzangolab/fastify/issues/135)) ([1b442d0](https://github.com/dzangolab/fastify/commit/1b442d0834fca2df097b4ad836e0abbf4a0914a5)) - - +- **fastify-mailer:** Add support for template data from config ([#135](https://github.com/dzangolab/fastify/issues/135)) ([1b442d0](https://github.com/dzangolab/fastify/commit/1b442d0834fca2df097b4ad836e0abbf4a0914a5)) # [0.12.0](https://github.com/dzangolab/fastify/compare/v0.11.2...v0.12.0) (2022-12-27) - ### Features -* add filter and sort on slonik ([#114](https://github.com/dzangolab/fastify/issues/114)) ([7c8b7a6](https://github.com/dzangolab/fastify/commit/7c8b7a647d4192339deaf770c834b55eafdbc133)), closes [#119](https://github.com/dzangolab/fastify/issues/119) - - +- add filter and sort on slonik ([#114](https://github.com/dzangolab/fastify/issues/114)) ([7c8b7a6](https://github.com/dzangolab/fastify/commit/7c8b7a647d4192339deaf770c834b55eafdbc133)), closes [#119](https://github.com/dzangolab/fastify/issues/119) ## [0.11.2](https://github.com/dzangolab/fastify/compare/v0.11.1...v0.11.2) (2022-12-27) - ### Bug Fixes -* **deps:** update typescript-eslint monorepo to v5.47.1 ([#123](https://github.com/dzangolab/fastify/issues/123)) ([3364c35](https://github.com/dzangolab/fastify/commit/3364c35cad8163af3ce7f357779da0f8462fec6e)) - - +- **deps:** update typescript-eslint monorepo to v5.47.1 ([#123](https://github.com/dzangolab/fastify/issues/123)) ([3364c35](https://github.com/dzangolab/fastify/commit/3364c35cad8163af3ce7f357779da0f8462fec6e)) ## [0.11.1](https://github.com/dzangolab/fastify/compare/v0.11.0...v0.11.1) (2022-12-25) - - # [0.11.0](https://github.com/dzangolab/fastify/compare/v0.10.8...v0.11.0) (2022-12-21) - ### Features -* **slonik:** change slonik.migrations config type ([#115](https://github.com/dzangolab/fastify/issues/115)) ([f8b0abf](https://github.com/dzangolab/fastify/commit/f8b0abf4190efbaf168efe275e042810483ee18e)) - - +- **slonik:** change slonik.migrations config type ([#115](https://github.com/dzangolab/fastify/issues/115)) ([f8b0abf](https://github.com/dzangolab/fastify/commit/f8b0abf4190efbaf168efe275e042810483ee18e)) ## [0.10.8](https://github.com/dzangolab/fastify/compare/v0.10.7...v0.10.8) (2022-12-20) - ### Bug Fixes -* **deps:** update typescript-eslint monorepo to v5.47.0 ([#112](https://github.com/dzangolab/fastify/issues/112)) ([acb039f](https://github.com/dzangolab/fastify/commit/acb039f53822ddcffc14c70ea786078984c762bf)) - - +- **deps:** update typescript-eslint monorepo to v5.47.0 ([#112](https://github.com/dzangolab/fastify/issues/112)) ([acb039f](https://github.com/dzangolab/fastify/commit/acb039f53822ddcffc14c70ea786078984c762bf)) ## [0.10.7](https://github.com/dzangolab/fastify/compare/v0.10.6...v0.10.7) (2022-12-18) - - ## [0.10.6](https://github.com/dzangolab/fastify/compare/v0.10.5...v0.10.6) (2022-12-18) - - ## [0.10.5](https://github.com/dzangolab/fastify/compare/v0.10.4...v0.10.5) (2022-12-18) - - ## [0.10.4](https://github.com/dzangolab/fastify/compare/v0.10.3...v0.10.4) (2022-12-18) - - ## [0.10.3](https://github.com/dzangolab/fastify/compare/v0.10.2...v0.10.3) (2022-12-18) - - ## [0.10.2](https://github.com/dzangolab/fastify/compare/v0.10.1...v0.10.2) (2022-12-18) - - ## [0.10.1](https://github.com/dzangolab/fastify/compare/v0.10.0...v0.10.1) (2022-12-18) - - # [0.10.0](https://github.com/dzangolab/fastify/compare/v0.9.2...v0.10.0) (2022-12-18) - ### Features -* **mailer:** add mjml and other plugins to nodemailer ([#101](https://github.com/dzangolab/fastify/issues/101)) ([b0fc6a2](https://github.com/dzangolab/fastify/commit/b0fc6a2af9967147b0465e2d24bf485c409b01df)) - - +- **mailer:** add mjml and other plugins to nodemailer ([#101](https://github.com/dzangolab/fastify/issues/101)) ([b0fc6a2](https://github.com/dzangolab/fastify/commit/b0fc6a2af9967147b0465e2d24bf485c409b01df)) ## [0.9.2](https://github.com/dzangolab/fastify/compare/v0.9.1...v0.9.2) (2022-12-18) - - ## [0.9.1](https://github.com/dzangolab/fastify/compare/v0.9.0...v0.9.1) (2022-12-17) - - # [0.9.0](https://github.com/dzangolab/fastify/compare/v0.8.6...v0.9.0) (2022-12-17) - - ## [0.8.6](https://github.com/dzangolab/fastify/compare/v0.8.5...v0.8.6) (2022-12-17) - ### Bug Fixes -* **deps:** update dependency eslint-plugin-unicorn to v45.0.2 ([#87](https://github.com/dzangolab/fastify/issues/87)) ([e146ad8](https://github.com/dzangolab/fastify/commit/e146ad8bb4a35cf1f90637e9e1c2743425e27426)) -* **deps:** update typescript-eslint monorepo to v5.46.1 ([#75](https://github.com/dzangolab/fastify/issues/75)) ([3573401](https://github.com/dzangolab/fastify/commit/35734018cc443efcdfb7e6ded775393285ff4160)) - - +- **deps:** update dependency eslint-plugin-unicorn to v45.0.2 ([#87](https://github.com/dzangolab/fastify/issues/87)) ([e146ad8](https://github.com/dzangolab/fastify/commit/e146ad8bb4a35cf1f90637e9e1c2743425e27426)) +- **deps:** update typescript-eslint monorepo to v5.46.1 ([#75](https://github.com/dzangolab/fastify/issues/75)) ([3573401](https://github.com/dzangolab/fastify/commit/35734018cc443efcdfb7e6ded775393285ff4160)) ## [0.8.5](https://github.com/dzangolab/fastify/compare/v0.8.4...v0.8.5) (2022-12-11) - - ## [0.8.4](https://github.com/dzangolab/fastify/compare/v0.8.3...v0.8.4) (2022-12-11) - - ## [0.8.3](https://github.com/dzangolab/fastify/compare/v0.8.2...v0.8.3) (2022-12-10) - - ## [0.8.2](https://github.com/dzangolab/fastify/compare/v0.8.1...v0.8.2) (2022-12-10) - - ## [0.8.1](https://github.com/dzangolab/fastify/compare/v0.7.0...v0.8.1) (2022-12-10) - - # [0.8.0](https://github.com/dzangolab/fastify/compare/v0.7.0...v0.8.0) (2022-12-10) - ### Features -* **mercurius:** add fastiify-mercurius plugin ([30aeb19](https://github.com/dzangolab/fastify/commit/30aeb19d2c97a5c7a6af4a15d276c62f4d8fce8a)) - - +- **mercurius:** add fastiify-mercurius plugin ([30aeb19](https://github.com/dzangolab/fastify/commit/30aeb19d2c97a5c7a6af4a15d276c62f4d8fce8a)) # [0.8.0](https://github.com/dzangolab/fastify/compare/v0.7.0...v0.8.0) (2022-12-10) - ### Features -* **mercurius:** add fastiify-mercurius plugin ([30aeb19](https://github.com/dzangolab/fastify/commit/30aeb19d2c97a5c7a6af4a15d276c62f4d8fce8a)) - - +- **mercurius:** add fastiify-mercurius plugin ([30aeb19](https://github.com/dzangolab/fastify/commit/30aeb19d2c97a5c7a6af4a15d276c62f4d8fce8a)) # [0.7.0](https://github.com/dzangolab/fastify/compare/v0.6.1...v0.7.0) (2022-12-10) - ### Features -* **config:** remove supertokens attribute ([ab65d71](https://github.com/dzangolab/fastify/commit/ab65d71bcbc961b0e9bdd84a3046659d35f1c0db)) - - +- **config:** remove supertokens attribute ([ab65d71](https://github.com/dzangolab/fastify/commit/ab65d71bcbc961b0e9bdd84a3046659d35f1c0db)) ## [0.6.1](https://github.com/dzangolab/fastify/compare/v0.6.0...v0.6.1) (2022-12-10) - ### Bug Fixes -* **deps:** update typescript-eslint monorepo to v5.46.0 ([#72](https://github.com/dzangolab/fastify/issues/72)) ([d6090cf](https://github.com/dzangolab/fastify/commit/d6090cfc72a9f2a48d83979eb5c845e144918aee)) - - +- **deps:** update typescript-eslint monorepo to v5.46.0 ([#72](https://github.com/dzangolab/fastify/issues/72)) ([d6090cf](https://github.com/dzangolab/fastify/commit/d6090cfc72a9f2a48d83979eb5c845e144918aee)) # [0.6.0](https://github.com/dzangolab/fastify/compare/v0.5.10...v0.6.0) (2022-12-08) - ### Features -* **config:** deprecate graphql and graphiql attributes from config ([1710a45](https://github.com/dzangolab/fastify/commit/1710a45a04e0e7e610d59ea38dce887de3d0006a)) - - +- **config:** deprecate graphql and graphiql attributes from config ([1710a45](https://github.com/dzangolab/fastify/commit/1710a45a04e0e7e610d59ea38dce887de3d0006a)) ## [0.5.10](https://github.com/dzangolab/fastify/compare/v0.5.9...v0.5.10) (2022-12-07) - ### Bug Fixes -* **deps:** update typescript-eslint monorepo to v5.45.1 ([#60](https://github.com/dzangolab/fastify/issues/60)) ([1794046](https://github.com/dzangolab/fastify/commit/1794046a473ad5ef64f0b2e0d85ddfe3064d0fdd)) - - +- **deps:** update typescript-eslint monorepo to v5.45.1 ([#60](https://github.com/dzangolab/fastify/issues/60)) ([1794046](https://github.com/dzangolab/fastify/commit/1794046a473ad5ef64f0b2e0d85ddfe3064d0fdd)) ## [0.5.9](https://github.com/dzangolab/fastify/compare/v0.5.8...v0.5.9) (2022-12-07) - ### Bug Fixes -* **slonik:** exclude postgres-migrations from build ([9c62397](https://github.com/dzangolab/fastify/commit/9c623976af227a0c49f54185154ad7db97799edb)) - - +- **slonik:** exclude postgres-migrations from build ([9c62397](https://github.com/dzangolab/fastify/commit/9c623976af227a0c49f54185154ad7db97799edb)) ## [0.5.8](https://github.com/dzangolab/fastify/compare/v0.5.7...v0.5.8) (2022-12-07) - ### Bug Fixes -* **slonik:** make postgres-migrations a peer dependency ([ea6fd38](https://github.com/dzangolab/fastify/commit/ea6fd38e802971b21a02c509f2f012d381f635cd)) - - +- **slonik:** make postgres-migrations a peer dependency ([ea6fd38](https://github.com/dzangolab/fastify/commit/ea6fd38e802971b21a02c509f2f012d381f635cd)) ## [0.5.7](https://github.com/dzangolab/fastify/compare/v0.5.6...v0.5.7) (2022-12-07) - - ## [0.5.6](https://github.com/dzangolab/fastify/compare/v0.5.5...v0.5.6) (2022-12-07) - - ## [0.5.5](https://github.com/dzangolab/fastify/compare/v0.5.4...v0.5.5) (2022-12-07) - ### Bug Fixes -* **slonik:** fix migrations path ([cbef31a](https://github.com/dzangolab/fastify/commit/cbef31a271f1b21e3f390e1bd811c2ca60c0ac57)) - - +- **slonik:** fix migrations path ([cbef31a](https://github.com/dzangolab/fastify/commit/cbef31a271f1b21e3f390e1bd811c2ca60c0ac57)) ## [0.5.4](https://github.com/dzangolab/fastify/compare/v0.5.3...v0.5.4) (2022-12-06) - ### Bug Fixes -* **slonik:** make fastify-slonik a peer dependency ([ff607ab](https://github.com/dzangolab/fastify/commit/ff607abd34c83ba21a5adf658c958d5284f18903)) -* **slonik:** update dependencies ([dd97082](https://github.com/dzangolab/fastify/commit/dd970829a0641179b0ec27f02ed54c3d98fef5f7)) - - +- **slonik:** make fastify-slonik a peer dependency ([ff607ab](https://github.com/dzangolab/fastify/commit/ff607abd34c83ba21a5adf658c958d5284f18903)) +- **slonik:** update dependencies ([dd97082](https://github.com/dzangolab/fastify/commit/dd970829a0641179b0ec27f02ed54c3d98fef5f7)) ## [0.5.3](https://github.com/dzangolab/fastify/compare/v0.5.2...v0.5.3) (2022-12-04) - ### Bug Fixes -* **slonik:** make postgres-migrations a peer dependency ([a720be0](https://github.com/dzangolab/fastify/commit/a720be0ddc82de670717cad182a749be1213b233)) - - +- **slonik:** make postgres-migrations a peer dependency ([a720be0](https://github.com/dzangolab/fastify/commit/a720be0ddc82de670717cad182a749be1213b233)) ## [0.5.2](https://github.com/dzangolab/fastify/compare/v0.5.1...v0.5.2) (2022-12-04) - ### Bug Fixes -* **slonik:** augment fastify types ([fc3cb75](https://github.com/dzangolab/fastify/commit/fc3cb759fbe3cd28557e0d25800a76b0d0b76e5c)) - - +- **slonik:** augment fastify types ([fc3cb75](https://github.com/dzangolab/fastify/commit/fc3cb759fbe3cd28557e0d25800a76b0d0b76e5c)) ## [0.5.1](https://github.com/dzangolab/fastify/compare/v0.3.2...v0.5.1) (2022-12-04) - - # [0.5.0](https://github.com/dzangolab/fastify/compare/v0.4.0...v0.5.0) (2022-12-03) - ### Features -* **slonik:** add fastify-slonik plugin ([#43](https://github.com/dzangolab/fastify/issues/43)) ([2da5b09](https://github.com/dzangolab/fastify/commit/2da5b09dfc1b67b802c22b573e2e1d9208586c4e)) - - +- **slonik:** add fastify-slonik plugin ([#43](https://github.com/dzangolab/fastify/issues/43)) ([2da5b09](https://github.com/dzangolab/fastify/commit/2da5b09dfc1b67b802c22b573e2e1d9208586c4e)) # [0.4.0](https://github.com/dzangolab/fastify/compare/v0.3.0...v0.4.0) (2022-12-03) - ### Features -* **config:** remove `db` attribute from ApiConfig ([#41](https://github.com/dzangolab/fastify/issues/41)) ([9b1ec37](https://github.com/dzangolab/fastify/commit/9b1ec375b72b166035625f1aa3be9b6581e19e88)) - - +- **config:** remove `db` attribute from ApiConfig ([#41](https://github.com/dzangolab/fastify/issues/41)) ([9b1ec37](https://github.com/dzangolab/fastify/commit/9b1ec375b72b166035625f1aa3be9b6581e19e88)) ## [0.3.2](https://github.com/dzangolab/fastify/compare/v0.3.1...v0.3.2) (2022-12-04) - ### Bug Fixes -* **config:** extract plugin as separate file ([#52](https://github.com/dzangolab/fastify/issues/52)) ([2685ae9](https://github.com/dzangolab/fastify/commit/2685ae96eecc2f1b8e907f2bd432db43b2404344)) - - +- **config:** extract plugin as separate file ([#52](https://github.com/dzangolab/fastify/issues/52)) ([2685ae9](https://github.com/dzangolab/fastify/commit/2685ae96eecc2f1b8e907f2bd432db43b2404344)) ## [0.3.1](https://github.com/dzangolab/fastify/compare/v0.2.1...v0.3.1) (2022-12-04) - - # [0.3.0](https://github.com/dzangolab/fastify/compare/v0.2.0...v0.3.0) (2022-12-03) - - ## [0.2.1](https://github.com/dzangolab/fastify/compare/v0.5.0...v0.2.1) (2022-12-04) - ### Bug Fixes -* **config:** fix export of ApiConfig type ([39ec736](https://github.com/dzangolab/fastify/commit/39ec73655d0fab488b33a8e8b9365d58b100dd9b)) - - +- **config:** fix export of ApiConfig type ([39ec736](https://github.com/dzangolab/fastify/commit/39ec73655d0fab488b33a8e8b9365d58b100dd9b)) # [0.5.0](https://github.com/dzangolab/fastify/compare/v0.4.0...v0.5.0) (2022-12-03) - ### Features -* **slonik:** add fastify-slonik plugin ([#43](https://github.com/dzangolab/fastify/issues/43)) ([2da5b09](https://github.com/dzangolab/fastify/commit/2da5b09dfc1b67b802c22b573e2e1d9208586c4e)) - - +- **slonik:** add fastify-slonik plugin ([#43](https://github.com/dzangolab/fastify/issues/43)) ([2da5b09](https://github.com/dzangolab/fastify/commit/2da5b09dfc1b67b802c22b573e2e1d9208586c4e)) # [0.4.0](https://github.com/dzangolab/fastify/compare/v0.3.0...v0.4.0) (2022-12-03) - ### Features -* **config:** remove `db` attribute from ApiConfig ([#41](https://github.com/dzangolab/fastify/issues/41)) ([9b1ec37](https://github.com/dzangolab/fastify/commit/9b1ec375b72b166035625f1aa3be9b6581e19e88)) - +- **config:** remove `db` attribute from ApiConfig ([#41](https://github.com/dzangolab/fastify/issues/41)) ([9b1ec37](https://github.com/dzangolab/fastify/commit/9b1ec375b72b166035625f1aa3be9b6581e19e88)) ## [0.3.3](https://github.com/dzangolab/fastify/compare/v0.3.2...v0.3.3) (2022-12-04) - - ### Bug Fixes -* **config:** extract plugin as separate file ([#52](https://github.com/dzangolab/fastify/issues/52)) ([2685ae9](https://github.com/dzangolab/fastify/commit/2685ae96eecc2f1b8e907f2bd432db43b2404344)) - - +- **config:** extract plugin as separate file ([#52](https://github.com/dzangolab/fastify/issues/52)) ([2685ae9](https://github.com/dzangolab/fastify/commit/2685ae96eecc2f1b8e907f2bd432db43b2404344)) ## [0.3.1](https://github.com/dzangolab/fastify/compare/v0.3.0...v0.3.1) (2022-12-04) - ### Bug Fixes -* **config:** fix export of ApiConfig type ([39ec736](https://github.com/dzangolab/fastify/commit/39ec73655d0fab488b33a8e8b9365d58b100dd9b)) - - +- **config:** fix export of ApiConfig type ([39ec736](https://github.com/dzangolab/fastify/commit/39ec73655d0fab488b33a8e8b9365d58b100dd9b)) # [0.3.0](https://github.com/dzangolab/fastify/compare/v0.2.0...v0.3.0) (2022-12-03) ### Features -* **config:** add parse function ([#39](https://github.com/dzangolab/fastify/issues/39)) ([907d8b4b](https://github.com/dzangolab/fastify/commit/907d84b013559064df2205d3f0f3956398c4b37b)) - -* **config:** add parse function ([#38](https://github.com/dzangolab/fastify/issues/38)) ([a56a50ee](https://github.com/dzangolab/fastify/commit/a56a50ee01d96011916677a01e648980b02ec2b3)) +- **config:** add parse function ([#39](https://github.com/dzangolab/fastify/issues/39)) ([907d8b4b](https://github.com/dzangolab/fastify/commit/907d84b013559064df2205d3f0f3956398c4b37b)) +- **config:** add parse function ([#38](https://github.com/dzangolab/fastify/issues/38)) ([a56a50ee](https://github.com/dzangolab/fastify/commit/a56a50ee01d96011916677a01e648980b02ec2b3)) ## [0.2.1](https://github.com/dzangolab/fastify/compare/v0.2.0...v0.2.1) (2022-12-04) - ### Bug Fixes -* **config:** fix export of ApiConfig type ([39ec736](https://github.com/dzangolab/fastify/commit/39ec73655d0fab488b33a8e8b9365d58b100dd9b)) - - +- **config:** fix export of ApiConfig type ([39ec736](https://github.com/dzangolab/fastify/commit/39ec73655d0fab488b33a8e8b9365d58b100dd9b)) # [0.2.0](https://github.com/dzangolab/fastify/compare/v0.1.0...v0.2.0) (2022-12-02) - ### Features -* **config:** remove logLevel attribute ([#35](https://github.com/dzangolab/fastify/issues/35)) ([6070617](https://github.com/dzangolab/fastify/commit/6070617fea8e235cfcdb974d6826490f9f7b62a5)) - - +- **config:** remove logLevel attribute ([#35](https://github.com/dzangolab/fastify/issues/35)) ([6070617](https://github.com/dzangolab/fastify/commit/6070617fea8e235cfcdb974d6826490f9f7b62a5)) # [0.1.0](https://github.com/dzangolab/fastify/compare/v0.0.14...v0.1.0) (2022-12-02) - ### Bug Fixes -* **deps:** update dependency eslint-config-turbo to v0.0.7 ([#32](https://github.com/dzangolab/fastify/issues/32)) ([cba3607](https://github.com/dzangolab/fastify/commit/cba360747ddea0258c3a910569d1a9b5d8dc07f2)) -* **deps:** update dependency eslint-plugin-unicorn to v45.0.1 ([#29](https://github.com/dzangolab/fastify/issues/29)) ([1216519](https://github.com/dzangolab/fastify/commit/1216519ae00866b58ed5037cbedad04fd15a43cc)) -* **deps:** update typescript-eslint monorepo to v5.45.0 ([#30](https://github.com/dzangolab/fastify/issues/30)) ([0b41dc0](https://github.com/dzangolab/fastify/commit/0b41dc0299e1d4660fe46470fa2decf7033d98f2)) - - +- **deps:** update dependency eslint-config-turbo to v0.0.7 ([#32](https://github.com/dzangolab/fastify/issues/32)) ([cba3607](https://github.com/dzangolab/fastify/commit/cba360747ddea0258c3a910569d1a9b5d8dc07f2)) +- **deps:** update dependency eslint-plugin-unicorn to v45.0.1 ([#29](https://github.com/dzangolab/fastify/issues/29)) ([1216519](https://github.com/dzangolab/fastify/commit/1216519ae00866b58ed5037cbedad04fd15a43cc)) +- **deps:** update typescript-eslint monorepo to v5.45.0 ([#30](https://github.com/dzangolab/fastify/issues/30)) ([0b41dc0](https://github.com/dzangolab/fastify/commit/0b41dc0299e1d4660fe46470fa2decf7033d98f2)) ## [0.0.14](https://github.com/dzangolab/fastify/compare/v0.0.13...v0.0.14) (2022-11-26) - - ## [0.0.13](https://github.com/dzangolab/fastify/compare/v0.0.12...v0.0.13) (2022-11-26) - - ## [0.0.12](https://github.com/dzangolab/fastify/compare/v0.0.11...v0.0.12) (2022-11-26) - - ## [0.0.11](https://github.com/dzangolab/fastify/compare/v0.0.10...v0.0.11) (2022-11-26) - - ## [0.0.10](https://github.com/dzangolab/fastify/compare/v0.0.9...v0.0.10) (2022-11-26) - ### Bug Fixes -* **deps:** update dependency eslint-plugin-unicorn to v45 ([#22](https://github.com/dzangolab/fastify/issues/22)) ([0ef20bd](https://github.com/dzangolab/fastify/commit/0ef20bd8fcc85aeef05b4ba345c5c349263e29e9)) -* **deps:** update dependency eslint-plugin-vue to v9.8.0 ([#19](https://github.com/dzangolab/fastify/issues/19)) ([cac06ea](https://github.com/dzangolab/fastify/commit/cac06ea2860e0294a48bbd9d493dc8f1b7e54c4c)) -* **deps:** update typescript-eslint monorepo to v5.44.0 ([#20](https://github.com/dzangolab/fastify/issues/20)) ([6a9a579](https://github.com/dzangolab/fastify/commit/6a9a579e3b241515d46a4c2e7a40de6e88999317)) - - +- **deps:** update dependency eslint-plugin-unicorn to v45 ([#22](https://github.com/dzangolab/fastify/issues/22)) ([0ef20bd](https://github.com/dzangolab/fastify/commit/0ef20bd8fcc85aeef05b4ba345c5c349263e29e9)) +- **deps:** update dependency eslint-plugin-vue to v9.8.0 ([#19](https://github.com/dzangolab/fastify/issues/19)) ([cac06ea](https://github.com/dzangolab/fastify/commit/cac06ea2860e0294a48bbd9d493dc8f1b7e54c4c)) +- **deps:** update typescript-eslint monorepo to v5.44.0 ([#20](https://github.com/dzangolab/fastify/issues/20)) ([6a9a579](https://github.com/dzangolab/fastify/commit/6a9a579e3b241515d46a4c2e7a40de6e88999317)) ## [0.0.9](https://github.com/dzangolab/fastify/compare/v0.0.6...v0.0.9) (2022-11-25) - - ## [0.0.8](https://github.com/dzangolab/fastify/compare/v0.0.6...v0.0.8) (2022-11-25) - - ## [0.0.7](https://github.com/dzangolab/fastify/compare/v0.0.6...v0.0.7) (2022-11-25) - - ## 0.0.6 (2022-11-24) - - ## 0.0.5 (2022-11-24) diff --git a/README.md b/README.md index 89bd32b84..d93bea1d7 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,68 @@ # @prefabs.tech/fastify -A set of fastify libraries +A set of fastify libraries ## Packages - - @prefabs.tech/fastify-config (https://www.npmjs.com/package/@prefabs.tech/fastify-config) - - @prefabs.tech/fastify-graphql (https://www.npmjs.com/package/@prefabs.tech/fastify-graphql) - - @prefabs.tech/fastify-mailer (https://www.npmjs.com/package/@prefabs.tech/fastify-mailer) - - @prefabs.tech/fastify-s3 (https://www.npmjs.com/package/@prefabs.tech/fastify-s3) - - @prefabs.tech/fastify-slonik (https://www.npmjs.com/package/@prefabs.tech/fastify-slonik) - - @prefabs.tech/fastify-user (https://www.npmjs.com/package/@prefabs.tech/fastify-user) + +- @prefabs.tech/fastify-config (https://www.npmjs.com/package/@prefabs.tech/fastify-config) +- @prefabs.tech/fastify-graphql (https://www.npmjs.com/package/@prefabs.tech/fastify-graphql) +- @prefabs.tech/fastify-mailer (https://www.npmjs.com/package/@prefabs.tech/fastify-mailer) +- @prefabs.tech/fastify-s3 (https://www.npmjs.com/package/@prefabs.tech/fastify-s3) +- @prefabs.tech/fastify-slonik (https://www.npmjs.com/package/@prefabs.tech/fastify-slonik) +- @prefabs.tech/fastify-user (https://www.npmjs.com/package/@prefabs.tech/fastify-user) ## Installation & Usage ### Install dependencies + Install dependencies recursively with this command + ``` make install ``` ### Build all packages + ``` make build ``` ### Lint code + ``` make lint ``` ### Typecheck code + ``` make typecheck ``` ### Test + ``` make test ``` ## Developing locally & testing + The best way to verify the changes done to the libraries is to test them locally before releasing them. To test libraries locally link each libraries to the `fastify-api` using `pnpm link` command. [More on pnpm link](https://pnpm.io/cli/link). To link and unlink the library locally run these commands from the `fastify-api` where you are linking the library: + ``` pnpm link .//packages/ ``` To unlink the linked library + ``` pnpm unlink .//packages/ ``` ## Troubleshooting - - Make sure that `package.json` and `pnpm-lock.yml` are synchronized. - - You may need to restart your fastify api before link and unlink to see the changes. - - All the libraries that defines or uses context has to be linked in order to link one libraries that use the context or defines it. + +- Make sure that `package.json` and `pnpm-lock.yml` are synchronized. +- You may need to restart your fastify api before link and unlink to see the changes. +- All the libraries that defines or uses context has to be linked in order to link one libraries that use the context or defines it. diff --git a/package.json b/package.json index 352b97ab2..ed658a81f 100644 --- a/package.json +++ b/package.json @@ -28,5 +28,14 @@ "engines": { "node": ">=20", "pnpm": ">=10" + }, + "pnpm": { + "overrides": { + "@typescript-eslint/eslint-plugin": "8.58.0", + "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "typescript-eslint": "8.58.0" + } } } diff --git a/packages/config/ADR-CONFIG.md b/packages/config/ADR-CONFIG.md new file mode 100644 index 000000000..142d86e4a --- /dev/null +++ b/packages/config/ADR-CONFIG.md @@ -0,0 +1,426 @@ +# ADR: Application Configuration Architecture + +**Date:** 2026-04-02 +**Status:** Proposed +**Decision Makers:** Engineering Team +**Affected Components:** `@prefabs.tech/fastify-config`, app-level configuration + +--- + +## Problem Statement + +Our multi-package Fastify application requires a configuration system that: + +1. **Supports extensibility** — Core packages (config, logger, mailer, firebase, etc.) need config, but the app layer may add domain-specific config (booking, event, redis, user profile exports) that packages can't know about beforehand +2. **Maintains type safety** — TypeScript should catch config shape mismatches at compile time +3. **Enables runtime validation** — Invalid or missing config should fail fast at startup, not at 3am in production +4. **Minimizes coupling** — The @config package shouldn't need to know about every package that needs configuration +5. **Remains maintainable** — Configuration architecture should be clear, with tradeoffs documented + +Current pain points: + +- `ApiConfig` type is centralized in `@config` but needs to be extended by the app for custom fields +- Config is constructed from environment variables without schema validation +- App-level config augmentation is scattered via TypeScript module declarations +- If a required env var is missing, the error appears at runtime, not at startup + +--- + +## Options Considered + +### Option 1: Centralized ApiConfig (Current Baseline) + +**How it works:** + +- All config fields defined in `ApiConfig` interface within `@config` package +- `@config` package "knows about" all packages and app-level needs +- Single point of truth for type definition +- Config decorator on fastify instance and request + +```ts +// @config/types.ts +export interface ApiConfig { + appName: string; + firebase: FirebaseConfig; + logger: LoggerConfig; + mailer: MailerConfig; + + // ... 20 more packages + booking: { + /* app-level */ + }; + event: { + /* app-level */ + }; + redis: { + /* app-level */ + }; + // ... custom app fields +} +``` + +**Tradeoffs:** + +| Aspect | Rating | Notes | +| ------------------- | -------------------- | -------------------------------------------------------- | +| **Extensibility** | ⭐ Poor | Every new config field requires changing @config package | +| **Type Safety** | ⭐⭐⭐⭐⭐ Excellent | Single source of truth, all fields typed | +| **Validation** | ⭐⭐ Poor | Manual object construction, no schema validation | +| **Coupling** | ⭐⭐ High | @config must know about all packages and app needs | +| **Maintainability** | ⭐⭐ Poor | ApiConfig grows unbounded; becomes a monolithic type | +| **Discoverability** | ⭐⭐⭐ Moderate | Need to look in one file, but it becomes huge | + +**When to use:** Small, stable applications with a fixed set of known config fields. + +--- + +### Option 2: Module Augmentation at App Level (Currently In Use) + +**How it works:** + +- Base `ApiConfig` defined in `@config` with core fields only +- App level extends ApiConfig via TypeScript module declaration +- Each package can also have its config interface extended at app level +- Config object constructed manually from env vars at app startup + +```ts +// @config/types.ts (minimal) +export interface ApiConfig { + appName: string; + env: string; + port: number; +} + +// app/config.ts (app level augmentation) +declare module "@prefabs.tech/fastify-config" { + interface ApiConfig { + booking: { + /* ... */ + }; + event: { + /* ... */ + }; + redis: { + /* ... */ + }; + } +} + +declare module "@prefabs.tech/fastify-mailer" { + interface MailerConfig { + bccTo?: string; + queueName: string; + } +} + +const config: ApiConfig = { + appName: process.env.APP_NAME as string, + booking: { + /* manually constructed */ + }, + // ... +}; +``` + +**Tradeoffs:** + +| Aspect | Rating | Notes | +| ------------------- | --------------- | -------------------------------------------------------------- | +| **Extensibility** | ⭐⭐⭐⭐ Good | App can add fields without touching packages | +| **Type Safety** | ⭐⭐⭐⭐ Good | TypeScript augmentation provides compile-time checks | +| **Validation** | ⭐ Poor | No schema validation; relies on type casting (`as string`) | +| **Coupling** | ⭐⭐⭐ Moderate | @config minimal, but app must know about all packages | +| **Maintainability** | ⭐⭐⭐ Moderate | Module declarations can be hard to track; spread across files | +| **Discoverability** | ⭐⭐ Poor | Config shape scattered across multiple `declare module` blocks | + +**When to use:** Multi-package systems where the app layer has significant custom config, and type safety is more important than runtime validation. + +**Current Status:** This is what the codebase currently implements. + +**Known Issues:** + +- Missing env vars return `undefined`, not caught until runtime +- No schema validation at startup +- If you do `config.appName.toLowerCase()` and `APP_NAME` env var is missing, crash at runtime + +--- + +### Option 3: Plugin-Driven Schema Composition (Recommended) + +**How it works:** + +- Each package exports a schema fragment for its config section +- App composes all schemas at startup (one place, one file) +- Zod (or similar) validates the entire config structure before decoration +- Single point of truth shifts from type definitions to schema + composition +- Runtime validation catches errors at startup, not in production + +```ts +// @config/validator.ts +export const coreConfigSchema = z.object({ + appName: z.string().min(1), + env: z.enum(["dev", "test", "prod"]), + port: z.number().positive(), +}) + +// @logger/config.ts +export const loggerConfigSchema = z.object({ + level: z.string(), + rotation: z.object({...}).optional(), +}) + +// @mailer/config.ts +export const mailerConfigSchema = z.object({ + host: z.string(), + port: z.number().positive(), +}) + +// app/config.ts - single composition point +import { coreConfigSchema } from "@config" +import { loggerConfigSchema } from "@logger" +import { mailerConfigSchema } from "@mailer" + +const appConfigSchema = z.object({ + ...coreConfigSchema.shape, + booking: z.object({...}), + event: z.object({...}), + logger: loggerConfigSchema.optional(), + mailer: mailerConfigSchema.optional(), + redis: z.object({...}), +}) + +// Validate at startup +const config = appConfigSchema.parse({ + appName: process.env.APP_NAME, + port: parseInt(process.env.PORT || "3000"), + // ... +}) +``` + +**Tradeoffs:** + +| Aspect | Rating | Notes | +| ------------------- | -------------------- | ---------------------------------------------------------------- | +| **Extensibility** | ⭐⭐⭐⭐⭐ Excellent | Add new config = add new schema fragment, no touching other code | +| **Type Safety** | ⭐⭐⭐⭐⭐ Excellent | Schemas infer types; TypeScript knows full shape via `z.infer<>` | +| **Validation** | ⭐⭐⭐⭐⭐ Excellent | Runtime schema validation at startup; fail fast | +| **Coupling** | ⭐⭐⭐⭐ Good | Packages export schemas; app composes them; minimal coupling | +| **Maintainability** | ⭐⭐⭐⭐ Good | One composition file; schemas live with packages; easy to track | +| **Discoverability** | ⭐⭐⭐⭐ Good | Config shape visible in app-level schema composition | + +**When to use:** Growing multi-package systems, microservices architectures, or any application where config extensibility and runtime safety are important. + +**Advantages:** + +- **Fail-fast guarantee** — Missing or invalid env vars error at startup +- **Single composition point** — All config decisions visible in one file +- **Clear ownership** — Each package owns its schema +- **Type + validation together** — Zod generates types from schema, no duplication +- **Testable** — Schemas can be unit-tested independently + +**Disadvantages:** + +- Requires adding Zod (or similar) dependency +- Slightly more setup initially +- Need to maintain both schema + values in construction + +--- + +### Option 4: Loose Config with Per-Package Validation (Not Recommended) + +**How it works:** + +- Minimal central config structure +- Each package validates its own config section at plugin registration time +- No central schema; validation scattered + +```ts +// @config - minimal +export const baseConfig = { appName, port, env }; + +// @logger - validates its own section +export const validateLoggerConfig = (cfg) => loggerConfigSchema.parse(cfg); + +fastify.register(loggerPlugin, { config: fullConfig }); +// loggerPlugin internally extracts and validates config.logger +``` + +**Tradeoffs:** + +| Aspect | Rating | Notes | +| ------------------- | -------------------- | ------------------------------------------------------ | +| **Extensibility** | ⭐⭐⭐⭐ Good | Packages are decoupled from central config | +| **Type Safety** | ⭐⭐ Poor | Per-package validation, no centralized type | +| **Validation** | ⭐⭐⭐ Moderate | Validation happens at plugin registration, not startup | +| **Coupling** | ⭐⭐⭐⭐⭐ Excellent | Maximum decoupling; packages validate independently | +| **Maintainability** | ⭐⭐ Poor | Validation logic scattered across packages | +| **Discoverability** | ⭐ Very Poor | Config shape is invisible until plugins register | + +**When to use:** Very loosely-coupled systems or if you want each package to be independently reusable. + +**Not recommended because:** Errors discovered late (at plugin registration time, not startup), config shape opaque. + +--- + +## Comparison Matrix + +| Criterion | Option 1: Centralized | Option 2: App Augmentation | Option 3: Schema Composition | Option 4: Per-Package | +| --------------- | --------------------- | -------------------------- | ---------------------------- | --------------------- | +| Fail-fast | ❌ No | ❌ No | ✅ Yes | ⚠️ Late | +| Type Safety | ✅ Good | ✅ Good | ✅ Excellent | ❌ Weak | +| Extensibility | ❌ Poor | ✅ Good | ✅ Excellent | ✅ Good | +| Coupling | ❌ High | ⚠️ Moderate | ✅ Low | ✅ Very Low | +| Simplicity | ✅ Simple | ✅ Simple | ⚠️ Moderate | ⚠️ Moderate | +| Discoverability | ⚠️ Moderate | ❌ Poor | ✅ Good | ❌ Poor | +| Maintenance | ❌ Monolithic | ⚠️ Scattered | ✅ Centralized | ❌ Scattered | + +--- + +## Recommendation: Option 3 (Plugin-Driven Schema Composition) + +**Why this option:** + +1. **Fail-fast guarantee** — The most critical issue with current setup (Option 2) is that missing env vars aren't caught until they're accessed. Schema validation at startup solves this. + +2. **Clear composition point** — One file shows the entire config shape. Easy to onboard new team members. + +3. **Package ownership** — Each package owns its schema fragment. Encourages good package design. + +4. **Type safety** — Zod infers types from schemas, so you get `z.infer` with full type safety, no manual interface management. + +5. **Scalability** — As you add more packages (redis, elasticsearch, queue systems, etc.), composition scales naturally. + +**Migration Path from Option 2 (current) → Option 3:** + +``` +Phase 1: Add validation without refactoring + - Keep current module augmentation + - Add Zod schema that mirrors current ApiConfig structure + - Validate at startup + +Phase 2: Extract package schemas + - Move logger schema from app to @logger package + - Move mailer schema from app to @mailer package + - Compose in app/config.ts + +Phase 3: Clean up + - Remove manual interface extensions (module augmentations) + - Let Zod infer types via z.infer<> + - Update tests +``` + +--- + +## Implementation Plan + +### If adopting Option 3: + +1. **Add dependency:** `npm install zod` (or pnpm in this monorepo) + +2. **Refactor @config package:** + + ```ts + // @config/validator.ts (new) + export const coreConfigSchema = z.object({ + appName: z.string().min(1), + appOrigin: z.array(z.string()), + baseUrl: z.string(), + env: z.enum(["dev", "test", "prod"]), + logger: z + .object({ + level: z.string(), + // ... nested fields + }) + .optional(), + port: z.number().positive(), + protocol: z.string(), + rest: z.object({ enabled: z.boolean() }), + version: z.string(), + }); + ``` + +3. **Each package exports schema:** + + ```ts + // @mailer/config.ts + export const mailerConfigSchema = z.object({ + bccTo: z.string().optional(), + host: z.string(), + port: z.number().positive(), + queueName: z.string(), + }); + ``` + +4. **App composes at startup:** + + ```ts + // app/config.ts + import { coreConfigSchema } from "@config" + import { mailerConfigSchema } from "@mailer" + + const appConfigSchema = z.object({ + ...coreConfigSchema.shape, + booking: z.object({...}), + mailer: mailerConfigSchema.optional(), + // ... app-specific + }) + + export const config = appConfigSchema.parse({ + appName: process.env.APP_NAME, + // ... construct from env + }) + ``` + +5. **Update fastify decorations:** + ```ts + // @config/index.ts + declare module "fastify" { + interface FastifyInstance { + config: z.infer; // Inferred from app schema + } + } + ``` + +--- + +## Consequences + +### Positive + +- ✅ **Fail-fast** — Invalid config caught at startup, not in production +- ✅ **Type-safe** — Full TypeScript coverage of config shape +- ✅ **Extensible** — Adding new config sections doesn't require changes to @config +- ✅ **Maintainable** — One clear composition point for all config +- ✅ **Testable** — Schemas can be unit-tested in isolation + +### Negative + +- ⚠️ **New dependency** — Adds Zod to the stack (but it's standard practice) +- ⚠️ **Slightly more boilerplate** — Schema definitions alongside construction +- ⚠️ **Learning curve** — Team needs to understand schema composition + +### Neutral + +- 🔹 **Migration effort** — Requires refactoring existing config code, but manageable +- 🔹 **Slight performance** — Schema validation at startup (negligible, runs once) + +--- + +## Decision + +**Adopt Option 3: Plugin-Driven Schema Composition** + +**Rationale:** + +- Solves the critical validation gap in the current approach (Option 2) +- Provides the scalability needed for a growing, multi-package application +- Aligns with Node.js best practices for configuration management +- Minimal risk, clear migration path from current state + +--- + +## References + +- Zod Documentation: https://zod.dev +- 12 Factor App - Config: https://12factor.net/config +- Node.js Best Practices - Configuration: https://github.com/goldbergyoni/nodebestpractices#6-configuration diff --git a/packages/config/FEATURES.md b/packages/config/FEATURES.md new file mode 100644 index 000000000..ba96d5067 --- /dev/null +++ b/packages/config/FEATURES.md @@ -0,0 +1,64 @@ + + +## Plugin Registration + +1. Registers as a Fastify plugin via `fastify-plugin` (no encapsulation — decorators are visible across the full app). +2. Accepts a single required option: `{ config: ApiConfig }`. +3. Provides no internal defaults for plugin options — callers must provide a complete `ApiConfig` object. + +## Fastify Decorators + +4. Decorates `FastifyInstance` with `config` (the full `ApiConfig` object). +5. Decorates `FastifyInstance` with `hostname` (derived as `${config.baseUrl}:${config.port}`). + +## Fastify Hooks + +6. Registers an `onRequest` hook that sets `request.config` to the same `ApiConfig` object on every incoming request. + +## Utility Functions + +7. Exports a `parse` utility for converting raw `string | undefined` env var values to typed values using a fallback to infer the target type: + - Returns `fallback` when `value` is `undefined`. + - Returns a `boolean` (via `!!JSON.parse(value)`) when `fallback` is a `boolean`. + - Returns a `number` (via `JSON.parse(value)`) when `fallback` is a `number`. + - Returns the raw `string` otherwise. + + ```typescript + parse(process.env.PORT, 3000); // → number + parse(process.env.DEBUG, false); // → boolean + parse(process.env.APP_NAME, "my-app"); // → string + parse(undefined, 3000); // → 3000 (fallback) + ``` + +## Type Exports & Module Augmentation + +8. Exports `ApiConfig` type — the shape of the full application configuration object. +9. Exports `AppConfig` type — the shape of an individual app entry within `ApiConfig.apps`. +10. Module-augments `FastifyInstance` to add `config: ApiConfig` and `hostname: string`. +11. Module-augments `FastifyRequest` to add `config: ApiConfig`. + +## ApiConfig Shape + +12. `ApiConfig` top-level fields: + - `appName: string` + - `appOrigin: string[]` + - `apps?: AppConfig[]` + - `baseUrl: string` + - `env: string` + - `logger` — see feature 12 + - `name: string` + - `pagination?: { default_limit: number; max_limit: number }` + - `port: number` + - `protocol: string` + - `rest: { enabled: boolean }` + - `version: string` + +13. `ApiConfig.logger` sub-object: + - `level: Level` (required) + - `base?: LoggerOptions["base"]` + - `formatters?: LoggerOptions["formatters"]` + - `prettyPrint?: { options: { colorize: boolean; ignore: string; translateTime: string } }` + - `rotation?: { enabled: boolean; options: { filenames: string[]; path: string; interval?: string; size?: string; maxFiles?: number; maxSize?: string; compress?: boolean | Compressor | string } }` + - `streams?: (DestinationStream | StreamEntry)[]` + - `timestamp?: LoggerOptions["timestamp"]` + - `transport?: LoggerOptions["transport"]` diff --git a/packages/config/GUIDE.md b/packages/config/GUIDE.md new file mode 100644 index 000000000..c9707145d --- /dev/null +++ b/packages/config/GUIDE.md @@ -0,0 +1,269 @@ +# @prefabs.tech/fastify-config — Developer Guide + +## Installation + +### For package consumers + +```bash +npm install @prefabs.tech/fastify-config +``` + +```bash +pnpm add @prefabs.tech/fastify-config +``` + +### For monorepo development + +```bash +pnpm install +pnpm --filter @prefabs.tech/fastify-config test +pnpm --filter @prefabs.tech/fastify-config build +``` + +## Setup + +Register the plugin once at startup, passing your config object. All subsequent examples assume this setup. + +```typescript +import Fastify from "fastify"; +import configPlugin, { type ApiConfig } from "@prefabs.tech/fastify-config"; + +const config: ApiConfig = { + appName: "my-api", + appOrigin: ["https://app.example.com"], + baseUrl: "http://localhost", + env: "production", + logger: { level: "info" }, + name: "my-api", + port: 3000, + protocol: "https", + rest: { enabled: true }, + version: "1.0.0", +}; + +const fastify = Fastify(); +await fastify.register(configPlugin, { config }); +``` + +--- + +## Base Libraries + +### `fastify-plugin` — Modified + +`fastify-plugin` provides Fastify plugin wrapping and metadata controls. + +-> **Their docs:** [`fastify-plugin`](https://www.npmjs.com/package/fastify-plugin) + +We wrap this library with a different surface: + +- Consumers do not pass `fastify-plugin` metadata options (`name`, `dependencies`, Fastify version metadata, etc.). +- The exposed API is only `fastify.register(configPlugin, { config })`. +- Internally we use the wrapper to expose our decorators and hooks application-wide. + +**What we add on top:** + +- `fastify.config` decorator with your `ApiConfig` object +- `fastify.hostname` decorator derived from `baseUrl` and `port` +- `request.config` population via `onRequest` +- Type exports and Fastify module augmentation +- `parse` utility for typed env parsing + +--- + +## Features + +### Plugin registration contract + +The plugin takes one required option (`config`) and does not apply internal defaults. Pass a full `ApiConfig` object at registration time. + +```typescript +await fastify.register(configPlugin, { + config: { + appName: "my-api", + appOrigin: ["https://app.example.com"], + baseUrl: "http://localhost", + env: "production", + logger: { level: "info" }, + name: "my-api", + port: 3000, + protocol: "https", + rest: { enabled: true }, + version: "1.0.0", + }, +}); +``` + +### `fastify.config` decorator + +After registration, the full `ApiConfig` object is available on every `FastifyInstance`: + +```typescript +fastify.get("/status", async () => { + return { env: fastify.config.env, version: fastify.config.version }; +}); +``` + +### `fastify.hostname` decorator + +A computed `hostname` string (`${config.baseUrl}:${config.port}`) is available on `FastifyInstance`: + +```typescript +fastify.get("/info", async () => { + return { url: fastify.hostname }; + // → "http://localhost:3000" +}); +``` + +### `request.config` on every request + +An `onRequest` hook makes `config` available on every `FastifyRequest`, so route handlers can access it without importing globals: + +```typescript +fastify.get("/me", async (request) => { + return { origin: request.config.appOrigin }; +}); +``` + +### `parse` — typed env var parser + +The exported `parse` utility converts raw environment variables to their intended types. Pass a fallback value; its type determines how the string is coerced. + +```typescript +import { parse } from "@prefabs.tech/fastify-config"; + +const config: ApiConfig = { + port: parse(process.env.PORT, 3000) as number, + env: parse(process.env.NODE_ENV, "development") as string, + // ... +}; +``` + +Rules: + +- `value === undefined` → returns `fallback` +- `typeof fallback === "boolean"` → `!!JSON.parse(value)` (`"true"`/`"1"` → `true`, `"false"`/`"0"` → `false`) +- `typeof fallback === "number"` → `JSON.parse(value)` +- Otherwise → returns `value` as-is (string) +- Invalid JSON-like input in boolean/number mode propagates `SyntaxError` from `JSON.parse`. + +### `ApiConfig` type + +The full application config shape. Top-level fields: + +| Field | Type | Description | +| ------------ | ------------------------------ | ------------------------------------- | +| `appName` | `string` | Application name | +| `appOrigin` | `string[]` | Allowed origins | +| `apps` | `AppConfig[]` | Optional sub-app list | +| `baseUrl` | `string` | Base URL (used to compute `hostname`) | +| `env` | `string` | Deployment environment | +| `logger` | object | Logger configuration (see below) | +| `name` | `string` | Service name | +| `pagination` | `{ default_limit, max_limit }` | Optional pagination defaults | +| `port` | `number` | Port (used to compute `hostname`) | +| `protocol` | `string` | Transport protocol | +| `rest` | `{ enabled: boolean }` | REST transport toggle | +| `version` | `string` | Application version | + +#### `logger` sub-object + +| Field | Type | Notes | +| ------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `level` | `Level` | Required pino log level | +| `base` | `LoggerOptions["base"]` | Pino base fields | +| `formatters` | `LoggerOptions["formatters"]` | Pino log formatters | +| `prettyPrint` | object | Pretty-print options (`colorize`, `ignore`, `translateTime`) | +| `rotation` | object | Log rotation (`enabled`, `filenames`, `path`, `interval`, `size`, `maxFiles`, `maxSize`, `compress`) | +| `streams` | `(DestinationStream \| StreamEntry)[]` | Pino stream list | +| `timestamp` | `LoggerOptions["timestamp"]` | Pino timestamp | +| `transport` | `LoggerOptions["transport"]` | Pino transport | + +### `AppConfig` type + +Shape of each entry in `ApiConfig.apps`: + +```typescript +interface AppConfig { + id: number; + name: string; + origin: string; + supportedRoles: string[]; +} +``` + +### TypeScript module augmentation + +The plugin ships with module augmentation for Fastify's types. No extra setup is needed — `fastify.config`, `fastify.hostname`, and `request.config` are typed automatically after importing from this package. + +--- + +## Use Cases + +### Building config from environment variables + +Use `parse` to construct a fully typed `ApiConfig` from `process.env`: + +```typescript +import { parse } from "@prefabs.tech/fastify-config"; +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +const config: ApiConfig = { + appName: parse(process.env.APP_NAME, "my-api") as string, + appOrigin: (process.env.APP_ORIGIN ?? "http://localhost:3000").split(","), + baseUrl: parse(process.env.BASE_URL, "http://localhost") as string, + env: parse(process.env.NODE_ENV, "development") as string, + logger: { + level: parse(process.env.LOG_LEVEL, "info") as string as Level, + }, + name: "my-api", + port: parse(process.env.PORT, 3000) as number, + protocol: parse(process.env.PROTOCOL, "http") as string, + rest: { enabled: parse(process.env.REST_ENABLED, true) as boolean }, + version: parse(process.env.APP_VERSION, "0.0.0") as string, +}; +``` + +### Accessing config in a route handler + +Config is available on both the instance and the request object, so you can use whichever is in scope: + +```typescript +// Via instance (useful in plugin scope) +fastify.get("/version", async () => ({ version: fastify.config.version })); + +// Via request (useful in route handlers without fastify in closure) +fastify.get("/origin", async (request) => ({ + origin: request.config.appOrigin, +})); +``` + +### Multi-app origin checking + +`ApiConfig.apps` lets you describe multiple sub-applications with their own origins and roles: + +```typescript +const config: ApiConfig = { + // ...base config... + apps: [ + { + id: 1, + name: "dashboard", + origin: "https://dash.example.com", + supportedRoles: ["admin"], + }, + { + id: 2, + name: "portal", + origin: "https://portal.example.com", + supportedRoles: ["user", "admin"], + }, + ], +}; + +fastify.addHook("onRequest", async (request) => { + const origin = request.headers.origin ?? ""; + const app = request.config.apps?.find((a) => a.origin === origin); + if (!app) throw fastify.httpErrors.forbidden("Unknown origin"); +}); +``` diff --git a/packages/config/README.md b/packages/config/README.md index 6964063d7..b87e44335 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -1,85 +1,84 @@ # @prefabs.tech/fastify-config -A [Fastify](https://github.com/fastify/fastify) plugin that defines an opinionated config for an API. +A [Fastify](https://github.com/fastify/fastify) plugin that provides opinionated, typed configuration management for APIs. -When registered on a Fastify instance, the plugin will: +## Why This Plugin? -* decorate the Fastify instance with the `config` object, available with the `config` attribute. -* decorate all requests with the `config` object, available with the `config` attribute; this can be used to construct a `buildContext` for mercurius resolvers, for example. -* decorate the Fastify instance with a `hostname` attribute. +In a complex API or monorepo with multiple Fastify plugins and services, maintaining a standardized configuration structure is critical. This plugin enforces a consistent config shape across services, centralizes config access at both the instance and request level, and provides a lightweight utility for parsing environment variables — without pulling in heavy validation dependencies like `zod` or `ajv`. -## Installation +### Why not Zod or @fastify/env? -Install with npm: +1. **No runtime validation overhead** — if your infrastructure (CI/CD, Docker, Kubernetes) guarantees correct environment variable injection, strict runtime validation is unnecessary overhead. +2. **Lightweight footprint** — no `ajv` or `zod` means less bundle size and fewer transitive dependencies. +3. **Manual type definitions** — hand-crafted TypeScript interfaces give immediate IDE support across your monorepo without extra build steps. -```bash -npm install @prefabs.tech/fastify-config -``` +## What You Get -Install with pnpm: +### Added by This Plugin -```bash -pnpm add --filter "@scope/project @prefabs.tech/fastify-config -``` +- **`fastify.config`** — decorates the Fastify instance with your `ApiConfig` object, accessible everywhere on the instance +- **`request.config`** — decorates every incoming request with the same config reference via an `onRequest` hook (useful for mercurius `buildContext`, route handlers, etc.) +- **`fastify.hostname`** — computed `${baseUrl}:${port}` string, derived from your config +- **`parse(value, fallback)`** — type-coercing env var parser: returns a boolean, number, or string based on the fallback type; returns the fallback when the value is `undefined` +- **`ApiConfig` type** — strongly typed interface covering app identity, origins, logging (pino), pagination, REST feature flag, and multi-tenant app list +- **`AppConfig` type** — per-app shape for multi-tenant configurations (`id`, `name`, `origin`, `supportedRoles`) + +→ [Full feature list](FEATURES.md) · [Developer guide](GUIDE.md) + +## Requirements + +**Peer dependencies** (must be installed separately): -## Usage +- [`fastify`](https://www.npmjs.com/package/fastify) `>=5.2.1` +- [`fastify-plugin`](https://www.npmjs.com/package/fastify-plugin) `>=5.0.1` -Somewhere in your code, create a `config.ts` file that looks like this: +No sibling `@prefabs.tech` plugins need to be registered before this one. + +## Quick Start ```typescript +// config.ts import { parse } from "@prefabs.tech/fastify-config"; -import dotenv from "dotenv"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; -dotenv.config(); - const config: ApiConfig = { appName: process.env.APP_NAME as string, appOrigin: (process.env.APP_ORIGIN as string).split(","), baseUrl: process.env.BASE_URL as string, env: parse(process.env.NODE_ENV, "development") as string, - logger: { - level: parse(process.env.LOG_LEVEL, "error") as string, - }, + logger: { level: parse(process.env.LOG_LEVEL, "error") as string }, name: process.env.NAME as string, - pagination: { - default_limit: parse(process.env.PAGINATION_DEFAULT_LIMIT, 25) as number, - max_limit: parse(process.env.PAGINATION_MAX_LIMIT, 50) as number, - }, - port: parse(process.env.PORT, 20040) as number, + port: parse(process.env.PORT, 3000) as number, protocol: parse(process.env.PROTOCOL, "http") as string, - rest: { - enabled: parse(process.env.REST_ENABLED, true) as boolean, - }, - version: `${process.env.npm_package_version || process.env.API_VERSION}+${process.env.API_BUILD || "local"}` as string, + rest: { enabled: parse(process.env.REST_ENABLED, true) as boolean }, + version: `${process.env.npm_package_version}+${process.env.BUILD_ID || "local"}`, }; export default config; ``` -Register the plugin with your Fastify instance: - ```typescript +// server.ts import configPlugin from "@prefabs.tech/fastify-config"; import Fastify from "fastify"; - import config from "./config"; -const start = async () => { - // Create fastify instance - const fastify = Fastify({ - logger: config.logger, - }); +const fastify = Fastify({ logger: config.logger }); +await fastify.register(configPlugin, { config }); - // Register fastify-config plugin - await fastify.register(configPlugin, { config }); +await fastify.listen({ port: config.port, host: "0.0.0.0" }); +``` - await fastify.listen({ - port: config.port, - host: "0.0.0.0", - }); -}; +## Installation + +Install with npm: -start(); +```bash +npm install @prefabs.tech/fastify-config +``` + +Install with pnpm: + +```bash +pnpm add @prefabs.tech/fastify-config ``` diff --git a/packages/config/eslint.config.js b/packages/config/eslint.config.js index 48a1291a4..7369a1f05 100644 --- a/packages/config/eslint.config.js +++ b/packages/config/eslint.config.js @@ -1,3 +1,22 @@ import fastifyConfig from "@prefabs.tech/eslint-config/fastify.js"; +import perfectionist from "eslint-plugin-perfectionist"; -export default fastifyConfig; +export default [ + ...fastifyConfig, + { + plugins: { + perfectionist, + }, + rules: { + // Disable conflicting default/import rules + "sort-imports": "off", + "import/order": "off", + + // Enable and spread Perfectionist's recommended rules + ...perfectionist.configs["recommended-alphabetical"].rules, + + // Add any Fastify-specific rule overrides here + "@typescript-eslint/no-explicit-any": "error", + }, + }, +]; diff --git a/packages/config/package.json b/packages/config/package.json index 8eb7918d5..d66e0e555 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -36,6 +36,7 @@ "@types/node": "24.10.13", "@vitest/coverage-istanbul": "3.2.4", "eslint": "9.39.2", + "eslint-plugin-perfectionist": "5.8.0", "fastify": "5.7.4", "fastify-plugin": "5.1.0", "pino": "8.21.0", diff --git a/packages/config/src/__test__/parse.test.ts b/packages/config/src/__test__/parse.test.ts index 67beec5cb..45f2913c6 100644 --- a/packages/config/src/__test__/parse.test.ts +++ b/packages/config/src/__test__/parse.test.ts @@ -37,19 +37,31 @@ describe("parse", () => { expect(parse(undefined, undefined)).toBe(undefined); }); - it("throws SyntaxError Exception due to json parse on boolean", () => { - try { - parse("Dzango", false); - } catch (error) { - expect(error).toBeInstanceOf(SyntaxError); - } - }); - - it("returns SyntaxError Exception due to json parse on number", () => { - try { - parse("Dzango", 14); - } catch (error) { - expect(error).toBeInstanceOf(SyntaxError); - } + it("throws SyntaxError when boolean parsing receives invalid JSON", () => { + expect(() => parse("Dzango", false)).toThrow(SyntaxError); + }); + + it("throws SyntaxError when number parsing receives invalid JSON", () => { + expect(() => parse("Dzango", 14)).toThrow(SyntaxError); + }); + + it('parses "1" as truthy boolean', () => { + expect(parse("1", false)).toBe(true); + }); + + it('parses "0" as falsy boolean', () => { + expect(parse("0", true)).toBe(false); + }); + + it("parses a float number", () => { + expect(parse("3.14", 0)).toBe(3.14); + }); + + it("parses a negative number", () => { + expect(parse("-5", 0)).toBe(-5); + }); + + it("returns empty string when value is empty string", () => { + expect(parse("", "default")).toBe(""); }); }); diff --git a/packages/config/src/__test__/plugin.test.ts b/packages/config/src/__test__/plugin.test.ts new file mode 100644 index 000000000..02e64f777 --- /dev/null +++ b/packages/config/src/__test__/plugin.test.ts @@ -0,0 +1,289 @@ +/* istanbul ignore file */ +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { ApiConfig } from "../types"; + +import configPlugin from "../plugin"; + +const baseConfig: ApiConfig = { + appName: "TestApp", + appOrigin: ["http://localhost:3000"], + baseUrl: "http://localhost", + env: "test", + logger: { level: "silent" }, + name: "test-api", + port: 3000, + protocol: "http", + rest: { enabled: true }, + version: "1.0.0+test", +}; + +describe("configPlugin — registration", () => { + it("registers without throwing given a valid config", async () => { + const fastify = Fastify({ logger: false }); + await expect( + fastify.register(configPlugin, { config: baseConfig }), + ).resolves.not.toThrow(); + await fastify.close(); + }); + + it("fails registration when config option is not provided", async () => { + const fastify = Fastify({ logger: false }); + + await expect( + fastify.register( + configPlugin, + {} as unknown as { + config: ApiConfig; + }, + ), + ).rejects.toThrow(); + + await fastify.close(); + }); +}); + +describe("configPlugin — fastify.config decorator", () => { + let fastify: ReturnType; + + beforeEach(async () => { + fastify = Fastify({ logger: false }); + await fastify.register(configPlugin, { config: baseConfig }); + await fastify.ready(); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("exposes the exact config object on fastify.config", () => { + expect(fastify.config).toBe(baseConfig); + }); + + it("exposes correct appName", () => { + expect(fastify.config.appName).toBe("TestApp"); + }); + + it("exposes correct port", () => { + expect(fastify.config.port).toBe(3000); + }); + + it("exposes correct env", () => { + expect(fastify.config.env).toBe("test"); + }); + + it("exposes correct version", () => { + expect(fastify.config.version).toBe("1.0.0+test"); + }); + + it("exposes correct appOrigin array", () => { + expect(fastify.config.appOrigin).toEqual(["http://localhost:3000"]); + }); + + it("exposes correct rest.enabled flag", () => { + expect(fastify.config.rest.enabled).toBe(true); + }); + + it("optional apps field is undefined when not provided", () => { + expect(fastify.config.apps).toBeUndefined(); + }); + + it("optional pagination field is undefined when not provided", () => { + expect(fastify.config.pagination).toBeUndefined(); + }); +}); + +describe("configPlugin — fastify.hostname decorator", () => { + it("computes hostname as baseUrl:port", async () => { + const fastify = Fastify({ logger: false }); + await fastify.register(configPlugin, { config: baseConfig }); + await fastify.ready(); + + expect(fastify.hostname).toBe("http://localhost:3000"); + await fastify.close(); + }); + + it("reflects different baseUrl and port values", async () => { + const fastify = Fastify({ logger: false }); + const config: ApiConfig = { + ...baseConfig, + baseUrl: "https://api.example.com", + port: 8080, + }; + await fastify.register(configPlugin, { config }); + await fastify.ready(); + + expect(fastify.hostname).toBe("https://api.example.com:8080"); + await fastify.close(); + }); +}); + +describe("configPlugin — req.config request decorator", () => { + let fastify: ReturnType; + + beforeEach(async () => { + fastify = Fastify({ logger: false }); + await fastify.register(configPlugin, { config: baseConfig }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("populates req.config in a GET route handler", async () => { + fastify.get("/test", async (req) => { + return { appName: req.config.appName }; + }); + + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ appName: "TestApp" }); + }); + + it("populates req.config in a POST route handler", async () => { + fastify.post("/test", async (req) => { + return { version: req.config.version }; + }); + + const res = await fastify.inject({ method: "POST", url: "/test" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ version: "1.0.0+test" }); + }); + + it("req.config is the same object as fastify.config", async () => { + let requestConfig: ApiConfig | undefined; + + fastify.get("/test", async (req) => { + requestConfig = req.config; + return {}; + }); + + await fastify.inject({ method: "GET", url: "/test" }); + expect(requestConfig).toBe(baseConfig); + }); + + it("req.config is available on every request", async () => { + fastify.get("/test", async (req) => { + return { env: req.config.env }; + }); + + const [res1, res2] = await Promise.all([ + fastify.inject({ method: "GET", url: "/test" }), + fastify.inject({ method: "GET", url: "/test" }), + ]); + + expect(res1.json()).toEqual({ env: "test" }); + expect(res2.json()).toEqual({ env: "test" }); + }); + + it("config values are stable across multiple requests (no mutation)", async () => { + fastify.get("/test", async (req) => { + return { port: req.config.port }; + }); + + const results = await Promise.all( + Array.from({ length: 5 }, () => + fastify.inject({ method: "GET", url: "/test" }), + ), + ); + + for (const res of results) { + expect(res.json()).toEqual({ port: 3000 }); + } + }); +}); + +describe("configPlugin — app-wide visibility (fastify-plugin)", () => { + it("exposes fastify.config, fastify.hostname, and req.config inside a nested child plugin", async () => { + const fastify = Fastify({ logger: false }); + await fastify.register(configPlugin, { config: baseConfig }); + + await fastify.register(async function nestedChildPlugin(child) { + child.get("/nested", async (request) => { + return { + hostname: child.hostname, + instanceHasConfig: "config" in child, + instanceHasHostname: "hostname" in child, + requestAppName: request.config.appName, + }; + }); + }); + + await fastify.ready(); + const res = await fastify.inject({ method: "GET", url: "/nested" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + hostname: "http://localhost:3000", + instanceHasConfig: true, + instanceHasHostname: true, + requestAppName: "TestApp", + }); + await fastify.close(); + }); +}); + +describe("configPlugin — optional config fields", () => { + it("exposes apps array when provided", async () => { + const fastify = Fastify({ logger: false }); + const config: ApiConfig = { + ...baseConfig, + apps: [ + { + id: 1, + name: "WebApp", + origin: "https://web.example.com", + supportedRoles: ["admin", "user"], + }, + ], + }; + await fastify.register(configPlugin, { config }); + + fastify.get("/test", async (req) => { + return { apps: req.config.apps }; + }); + + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().apps).toHaveLength(1); + expect(res.json().apps[0].name).toBe("WebApp"); + await fastify.close(); + }); + + it("exposes pagination defaults when provided", async () => { + const fastify = Fastify({ logger: false }); + const config: ApiConfig = { + ...baseConfig, + pagination: { default_limit: 20, max_limit: 100 }, + }; + await fastify.register(configPlugin, { config }); + await fastify.ready(); + + expect(fastify.config.pagination?.default_limit).toBe(20); + expect(fastify.config.pagination?.max_limit).toBe(100); + await fastify.close(); + }); + + it("exposes apps array via req.config inside a route", async () => { + const fastify = Fastify({ logger: false }); + const config: ApiConfig = { + ...baseConfig, + apps: [ + { + id: 2, + name: "MobileApp", + origin: "https://mobile.example.com", + supportedRoles: ["user"], + }, + ], + }; + await fastify.register(configPlugin, { config }); + + fastify.get("/test", async (req) => { + return { firstApp: req.config.apps?.[0]?.name }; + }); + + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json()).toEqual({ firstApp: "MobileApp" }); + await fastify.close(); + }); +}); diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 3e4ee33b1..1b90b72ea 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -11,8 +11,8 @@ declare module "fastify" { } } -export { default } from "./plugin"; - export { default as parse } from "./parse"; +export { default } from "./plugin"; + export type { ApiConfig, AppConfig } from "./types"; diff --git a/packages/config/src/plugin.ts b/packages/config/src/plugin.ts index ef71d0fc3..70311c8c1 100644 --- a/packages/config/src/plugin.ts +++ b/packages/config/src/plugin.ts @@ -1,7 +1,8 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; + import FastifyPlugin from "fastify-plugin"; import type { ApiConfig } from "./types"; -import type { FastifyInstance, FastifyRequest } from "fastify"; const plugin = async ( fastify: FastifyInstance, diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 59b0eea14..56060f738 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -5,15 +5,6 @@ import type { StreamEntry, } from "pino"; -interface AppConfig { - id: number; - name: string; - origin: string; - supportedRoles: string[]; -} - -type Compressor = (source: string, destination: string) => string; - interface ApiConfig { appName: string; appOrigin: string[]; @@ -34,7 +25,7 @@ interface ApiConfig { rotation?: { enabled: boolean; options: { - compress?: boolean | string | Compressor; + compress?: boolean | Compressor | string; filenames: string[]; interval?: string; maxFiles?: number; @@ -44,8 +35,8 @@ interface ApiConfig { }; }; streams?: (DestinationStream | StreamEntry)[]; - transport?: LoggerOptions["transport"]; timestamp?: LoggerOptions["timestamp"]; + transport?: LoggerOptions["transport"]; }; name: string; pagination?: { @@ -60,4 +51,13 @@ interface ApiConfig { version: string; } +interface AppConfig { + id: number; + name: string; + origin: string; + supportedRoles: string[]; +} + +type Compressor = (source: string, destination: string) => string; + export type { ApiConfig, AppConfig }; diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json index 8a8ad62d0..1628077b9 100644 --- a/packages/config/tsconfig.json +++ b/packages/config/tsconfig.json @@ -1,13 +1,9 @@ { "extends": "@prefabs.tech/tsconfig/fastify.json", - "exclude": [ - "src/**/__test__/**/*", - ], + "exclude": ["src/**/__test__/**/*"], "compilerOptions": { "baseUrl": "./", - "outDir": "./dist", + "outDir": "./dist" }, - "include": [ - "src/**/*.ts" - ] + "include": ["src/**/*.ts"] } diff --git a/packages/config/vite.config.ts b/packages/config/vite.config.ts index 00d158a4c..6812de0a4 100644 --- a/packages/config/vite.config.ts +++ b/packages/config/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { peerDependencies } from "./package.json"; diff --git a/packages/error-handler/FEATURES.md b/packages/error-handler/FEATURES.md new file mode 100644 index 000000000..eee16def9 --- /dev/null +++ b/packages/error-handler/FEATURES.md @@ -0,0 +1,69 @@ + + +# @prefabs.tech/fastify-error-handler — Features + +## Plugin Registration + +1. **Registers `@fastify/sensible` with fixed defaults** — adds `fastify.httpErrors` and `HttpError` support automatically; this package does not expose `@fastify/sensible` registration options. + +2. **Adds `ErrorResponse` JSON schema** — registers the `ErrorResponse` schema (`$id: "ErrorResponse"`) with Fastify so routes can reference it in response schemas. + +3. **`stackTrace` decorator** — decorates the Fastify instance with `fastify.stackTrace: boolean`, defaulting to `false`. + +## Error Handler + +4. **Global `setErrorHandler`** — installs a single error handler that catches all unhandled errors thrown from routes and plugins. + +5. **Unknown error normalization** — non-`Error` values thrown (e.g. strings, null) are coerced to `new Error("UNKNOWN_ERROR")` before processing. + +6. **HttpError branch** — errors that are `instanceof HttpError` (thrown via `fastify.httpErrors.*`) respond with the original status code, HTTP status text in `error`, and the original message and name. + +7. **Non-HttpError branch** — all other errors (plain `Error`, `CustomError`, subclasses) always respond with status `500`. + +8. **`CustomError` code extraction** — when the thrown error is `instanceof CustomError`, its `.code` is used in the response (only when `stackTrace: true`; otherwise `"INTERNAL_SERVER_ERROR"` is used). + +9. **Error detail masking** (`stackTrace: false`) — for non-HttpErrors, the response replaces message, name, and code with safe generic values: + - Plain `Error`: message → `"Server error, please contact support"`, name → `"Error"`, code → `"INTERNAL_SERVER_ERROR"` + - `CustomError`: message → `"Server has an error that is not handled, please contact support"`, name → `"Error"`, code → `"INTERNAL_SERVER_ERROR"` + +10. **Severity-based logging for HttpErrors** — `5xx` logged at `error` level; `4xx` logged at `info` level; below `400` logged at `error` level. + +11. **Non-HttpError always logged at `error` level** — regardless of `stackTrace` setting. + +## Stack Traces + +12. **Optional stack trace in responses** — when `stackTrace: true`, error responses include a `stack` array of parsed `StackTracey.Entry` objects (file, line, column, callee) for both HttpErrors and non-HttpErrors. + +13. **Stack trace gated on `error.stack` presence** — if the error has no `.stack` property, the `stack` field is omitted from the response even when `stackTrace: true`. + +## Pre-Error Handler + +14. **`preErrorHandler` option** — an optional async function called before the default handler, receiving `(error, request, reply)`. Useful for third-party library error handling (e.g. SuperTokens, Passport.js). + +15. **Reply-sent short-circuit** — if `preErrorHandler` sends the reply (`reply.sent === true`), the default error handler is skipped entirely. + +16. **Silent exception suppression in `preErrorHandler`** — if `preErrorHandler` throws, the exception is caught and discarded; the default error handler always runs after. + +## Error Response Format + +17. **Consistent `ErrorResponse` shape** — every error response conforms to: + ```typescript + { + code?: string; // error code (HttpErrors: from .code; non-HttpErrors: custom or "INTERNAL_SERVER_ERROR") + error?: string; // HTTP status text (HttpErrors only) + message: string; // error message (masked for non-HttpErrors when stackTrace: false) + name: string; // error class name (masked to "Error" for non-HttpErrors when stackTrace: false) + stack?: StackTracey.Entry[] // parsed stack frames (only when stackTrace: true) + statusCode: number; // HTTP status code + } + ``` + +## Exports + +18. **`errorHandler` function** — exported standalone for use outside the plugin registration context. + +19. **`CustomError` class** — base class for application errors with a `code` string field. + +20. **Type exports** — `ErrorHandlerOptions`, `ErrorHandler`, `ErrorResponse` exported for use in consuming code. + +21. **Re-exports** — `HttpErrors` (from `@fastify/sensible`) and `StackTracey` (from `stacktracey`) re-exported for convenience. diff --git a/packages/error-handler/GUIDE.md b/packages/error-handler/GUIDE.md new file mode 100644 index 000000000..2bdbb8be0 --- /dev/null +++ b/packages/error-handler/GUIDE.md @@ -0,0 +1,293 @@ +# @prefabs.tech/fastify-error-handler — Developer Guide + +## Installation + +### For package consumers + +```bash +npm install @prefabs.tech/fastify-error-handler +``` + +```bash +pnpm add @prefabs.tech/fastify-error-handler +``` + +### For monorepo development + +```bash +pnpm install +pnpm --filter @prefabs.tech/fastify-error-handler test +pnpm --filter @prefabs.tech/fastify-error-handler build +``` + +## Setup + +Register the plugin once at startup. All subsequent examples assume this setup. + +```typescript +import Fastify from "fastify"; +import errorHandlerPlugin from "@prefabs.tech/fastify-error-handler"; + +const fastify = Fastify(); + +await fastify.register(errorHandlerPlugin, { + stackTrace: false, // optional, default: false + preErrorHandler: undefined, // optional +}); +``` + +--- + +## Base Libraries + +### @fastify/sensible — Modified + +Provides HTTP error factory methods and the `HttpError` class. + +→ **Their docs:** [@fastify/sensible](https://github.com/fastify/fastify-sensible) + +`@fastify/sensible` is registered automatically with its defaults, but this package does not expose `@fastify/sensible` plugin options. + +**What we add on top:** Unified error handling for `HttpError` instances with severity-based logging, response shaping, optional stack trace output, and pre-handler interception via `preErrorHandler`. + +### stacktracey — Modified + +Parses `Error.stack` into structured frames. + +→ **Their docs:** [stacktracey](https://www.npmjs.com/package/stacktracey) + +We use `StackTracey` internally to parse stack traces and expose them in error responses as `StackTracey.Entry[]` arrays. + +**What we add on top:** `StackTracey` is re-exported from this package for use in consuming code. + +--- + +## Features + +### Global error handler + +The plugin installs a `setErrorHandler` that catches all unhandled errors thrown from routes and plugins. Non-`Error` values (strings, `null`, etc.) are coerced to `new Error("UNKNOWN_ERROR")` before processing. + +```typescript +fastify.get("/boom", async () => { + throw new Error("Something went wrong"); +}); +// → 500 response with masked message +``` + +### HttpError handling + +Errors that are `instanceof HttpError` — thrown via `fastify.httpErrors.*` — respond with the error's original status code, `error` (HTTP status text), `message`, and `name`. + +```typescript +fastify.get("/forbidden", async () => { + throw fastify.httpErrors.forbidden("You cannot access this"); + // → 403 { statusCode: 403, error: "Forbidden", message: "You cannot access this", name: "..." } +}); +``` + +### Non-HttpError handling — error masking + +All non-`HttpError` errors (plain `Error`, `CustomError`, and any subclass) always respond with status `500`. When `stackTrace: false` (the default), internal details are masked: + +- `message` → `"Server error, please contact support"` +- `name` → `"Error"` +- `code` → `"INTERNAL_SERVER_ERROR"` + +For `CustomError` instances, message is replaced with `"Server has an error that is not handled, please contact support"`. + +When `stackTrace: true`, the actual `message`, `name`, and `code` are included in the response. + +### Severity-based logging for HttpErrors + +The log level depends on the error's status code: + +- `5xx` → logged at `error` level +- `4xx` → logged at `info` level +- below `400` → logged at `error` level + +Non-HttpErrors are always logged at `error` level regardless of `stackTrace` setting. + +### `stackTrace` option and decorator + +Controls whether parsed stack frames appear in error responses. Defaults to `false`. + +```typescript +await fastify.register(errorHandlerPlugin, { stackTrace: true }); +``` + +The current value is accessible at runtime via `fastify.stackTrace`: + +```typescript +fastify.get("/debug", async () => { + return { stackTraceEnabled: fastify.stackTrace }; +}); +``` + +When enabled, error responses include a `stack` array of `StackTracey.Entry` objects (file, line, column, callee). The field is omitted if the error has no `.stack` property. + +### `preErrorHandler` option + +An optional async function called before the default error handler. Useful for intercepting errors from third-party libraries (e.g. auth middleware) before our default response logic runs. + +```typescript +await fastify.register(errorHandlerPlugin, { + preErrorHandler: async (error, request, reply) => { + if (isSupertokensError(error)) { + await SuperTokens.errorHandler()(error, request.raw, reply.raw, () => {}); + } + }, +}); +``` + +Behavior: + +- If `preErrorHandler` sends the reply (`reply.sent === true`), the default handler is skipped entirely. +- If `preErrorHandler` throws, the exception is silently discarded and the default handler still runs. + +### `ErrorResponse` JSON schema + +The plugin registers an `ErrorResponse` schema (`$id: "ErrorResponse"`) with Fastify so routes can reference it in their response schemas. + +```typescript +fastify.get("/data", { + schema: { + response: { + 400: { $ref: "ErrorResponse#" }, + 500: { $ref: "ErrorResponse#" }, + }, + }, + handler: async () => { + /* ... */ + }, +}); +``` + +### `CustomError` class + +A base class for application errors with an optional `code` string. Extends `Error` with correct prototype chain (`instanceof CustomError` and `instanceof Error` both work). + +```typescript +import { CustomError } from "@prefabs.tech/fastify-error-handler"; + +throw new CustomError("Payment failed", "PAYMENT_FAILED"); +// error.code === "PAYMENT_FAILED" +// error.name === "CustomError" +``` + +When `stackTrace: true`, the `code` and real `message` appear in the 500 response instead of generic placeholders. + +### Standalone `errorHandler` export + +The error handler function is exported for use outside the plugin — for example, to reuse in tests or to compose into a custom handler. + +```typescript +import { errorHandler } from "@prefabs.tech/fastify-error-handler"; + +fastify.setErrorHandler((error, request, reply) => { + // custom pre-processing... + return errorHandler(error, request, reply); +}); +``` + +### Error response format + +Every response from the error handler conforms to `ErrorResponse`: + +```typescript +type ErrorResponse = { + code?: string; // error code + error?: string; // HTTP status text (HttpErrors only) + message: string; // error message + name: string; // error class name + stack?: StackTracey.Entry[]; // parsed frames (only when stackTrace: true) + statusCode: number; // HTTP status code +}; +``` + +### Type and class exports + +| Export | Kind | Description | +| --------------------- | ----- | ------------------------------------ | +| `ErrorHandlerOptions` | type | Plugin options shape | +| `ErrorHandler` | type | Signature for `preErrorHandler` | +| `ErrorResponse` | type | Response body shape | +| `CustomError` | class | Base application error class | +| `HttpErrors` | type | Re-exported from `@fastify/sensible` | +| `StackTracey` | class | Re-exported from `stacktracey` | + +--- + +## Use Cases + +### Handling third-party auth library errors + +When using an auth library that uses its own error classes (e.g. SuperTokens), use `preErrorHandler` to intercept them before the default 500 handler fires: + +```typescript +import errorHandlerPlugin from "@prefabs.tech/fastify-error-handler"; +import supertokens from "supertokens-node"; + +await fastify.register(errorHandlerPlugin, { + preErrorHandler: async (error, request, reply) => { + if ( + supertokens.errorHandler && + supertokens.isCompatibleWithFastify(error) + ) { + await supertokens.errorHandler()(error, request.raw, reply.raw, () => {}); + } + }, +}); +``` + +### Structured application errors with codes + +Define domain-specific error subclasses using `CustomError` so that error codes survive into logs and (in dev/debug mode) into responses: + +```typescript +import { CustomError } from "@prefabs.tech/fastify-error-handler"; + +class PaymentError extends CustomError { + constructor(message: string) { + super(message, "PAYMENT_FAILED"); + } +} + +fastify.post("/pay", async () => { + throw new PaymentError("Card declined"); + // stackTrace: false → 500 with generic message + // stackTrace: true → 500 with code: "PAYMENT_FAILED", message: "Card declined" +}); +``` + +### Toggle response detail by app config + +Use an application config flag to control whether stack traces are exposed in error responses: + +```typescript +const appConfig = { exposeErrorStacks: false }; + +await fastify.register(errorHandlerPlugin, { + stackTrace: appConfig.exposeErrorStacks, +}); +``` + +### Referencing the error schema in route responses + +Reuse the registered `ErrorResponse` schema to keep your OpenAPI output consistent: + +```typescript +fastify.post("/users", { + schema: { + response: { + 201: userSchema, + 400: { $ref: "ErrorResponse#" }, + 409: { $ref: "ErrorResponse#" }, + 500: { $ref: "ErrorResponse#" }, + }, + }, + handler: async (request) => { + // ... + }, +}); +``` diff --git a/packages/error-handler/README.md b/packages/error-handler/README.md index 6d10845b1..8a13ac0f4 100644 --- a/packages/error-handler/README.md +++ b/packages/error-handler/README.md @@ -1,114 +1,108 @@ # @prefabs.tech/fastify-error-handler -A [Fastify](https://github.com/fastify/fastify) plugin that provides an easy integration of error handler in fastify API. +A [Fastify](https://github.com/fastify/fastify) plugin that provides a standardized, production-safe global error handler for APIs. -## Requirements +## Why This Plugin? -* [@prefabs.tech/fastify-config](../config/) -* [@fastify/sensible](https://github.com/fastify/fastify-sensible) +In a large API or microservice ecosystem, inconsistent error handling quickly leads to bloated controllers and unpredictable API responses for your frontend clients. We created this plugin to: -## Installation +- **Unify Your Error Responses**: By providing a global error formatter powered by `@fastify/sensible`, we ensure that no matter where an error originates — a database crash, a validation failure, or a manual throw — your API always responds with a standardized, predictable JSON shape. +- **Keep Controllers Clean**: We enforce an exceptions-based approach. Focus purely on the happy path in your route handlers. Instead of manually catching errors and calling `reply.code(400).send(...)`, you simply `throw` an error and let the global handler manage the rest. +- **Provide Safe Interception**: Fastify only allows one global `setErrorHandler`. If you use libraries like SuperTokens that require their own error handling, standard setups break. We designed a clean `preErrorHandler` option to let you safely run those third-party hooks before falling back to the standard global formatter. +- **Standardize Custom Exceptions**: We provide a strongly-typed `CustomError` base class so you can attach specific application error codes and metadata across your monorepo without resorting to raw strings or plain `Error` objects. -Install with npm: +## What You Get -```bash -npm install @prefabs.tech/fastify-error-handler -``` +### @fastify/sensible — Full Passthrough -Install with pnpm: +All options from [@fastify/sensible](https://www.npmjs.com/package/@fastify/sensible) are supported. This plugin registers it internally with no configuration, exposing `fastify.httpErrors.*` helpers on the instance. -```bash -pnpm add --filter "@scope/project @prefabs.tech/fastify-error-handler -``` +### Added by This Plugin + +- **Global error handler** — catches all thrown errors (HttpErrors, CustomErrors, plain Errors, and non-Error values) and formats them into a consistent `ErrorResponse` JSON shape +- **Safe message masking** — 5xx errors hide implementation details behind generic messages by default; `stackTrace: true` disables masking for development +- **`preErrorHandler` hook** — run custom logic (e.g. SuperTokens, Passport) before the default handler; short-circuits if your handler sends the reply, swallows exceptions otherwise +- **`CustomError` base class** — extend it to create domain errors with a custom `code` field; subclasses are handled safely +- **`stackTrace` decorator** — `fastify.stackTrace` (boolean) reflects the active setting, accessible to other plugins and hooks +- **`ErrorResponse` JSON schema** — registered as `$id: "ErrorResponse"` for use in route response schemas via `$ref: "ErrorResponse#"` +- **Severity-aware logging** — 4xx errors log at `info`, 5xx at `error`; non-Error thrown values are normalized and logged safely -## Usage +→ [Full feature list](FEATURES.md) · [Developer guide](GUIDE.md) -### Register Plugin +## Usage Guidelines -Register @prefabs.tech/fastify-error-handler package with your Fastify instance: +### Controllers must not reply with non-200 responses + +Do not manually send error responses from route handlers. Always `throw` and let the global error handler format the response. -Note: Register the errorHandler plugin as early as possible (Before all your routes and plugin registration). +**Wrong** ```typescript -import errorHandlerPlugin from "@prefabs.tech/fastify-error-handler"; -import Fastify from "fastify"; +fastify.get("/test", async (req, reply) => { + return reply.code(401).send({ message: "Unauthorized" }); +}); +``` + +**Correct** -const start = async () => { - // Create fastify instance - const fastify = Fastify(); - - // Register fastify-error-handler plugin - await fastify.register(errorHandlerPlugin, {}); - - await fastify.listen({ - port: config.port, - host: "0.0.0.0", - }); -}; - -start(); +```typescript +fastify.get("/test", async () => { + throw fastify.httpErrors.unauthorized("Unauthorized"); +}); ``` -### Options -#### stackTrace +### Throw `CustomError` (or a subclass) for domain errors -When enabled, the error handler will include the error’s stack trace in the HTTP response body. +Modules must throw an instance of `CustomError` (or a class extending it) for application-level errors. This ensures errors are caught consistently and the correct action can be taken. -By default, it is set to false. +```typescript +import { CustomError } from "@prefabs.tech/fastify-error-handler"; -```ts -stackTrace?: boolean; // Default: false +const file = await fileService.findById(id); +if (!file) { + throw new CustomError("File not found", "FILE_NOT_FOUND_ERROR"); +} ``` -#### preErrorHandler +## Requirements -preErrorHandler is an optional error handler that runs before the default error handler logic. -It allows you to intercept specific errors, handle them yourself, and prevent the default handler from running. +**Peer dependencies** (must be installed separately): -This is especially useful when you need to integrate with other libraries that have their own error formats — for example, handling SuperTokens errors before your API’s standard error response. +- [`fastify`](https://www.npmjs.com/package/fastify) `>=5.2.1` +- [`fastify-plugin`](https://www.npmjs.com/package/fastify-plugin) `>=5.0.1` -```ts -preErrorHandler?: ( - error: FastifyError, - request: FastifyRequest, - reply: FastifyReply, -) => void | Promise; -``` +Register this plugin **before all routes and other plugins** so the error handler is in place for the entire application. -## Error Handling Guidelines +## Quick Start -### Controllers must not reply with non-200 responses +```typescript +import errorHandlerPlugin from "@prefabs.tech/fastify-error-handler"; +import Fastify from "fastify"; -Do not manually send error responses from controllers. +const fastify = Fastify(); -Instead, always throw an error and let the global error handler handle formatting and response. +await fastify.register(errorHandlerPlugin, { + stackTrace: process.env.NODE_ENV === "development", +}); -**Wrong** +// Throw errors in routes — the handler does the rest +fastify.get("/example", async () => { + throw fastify.httpErrors.notFound("Resource not found"); +}); -```ts -fastify.get('/test', async (req, reply) => { - return reply.code(401).send({ message: "Unauthorized" }); -}) +await fastify.listen({ port: 3000, host: "0.0.0.0" }); ``` -**Correct** - -```ts -fastify.get('/test', async (req, reply) => { - throw fastify.httpErrors.unauthorized("Unauthorized"); -}) -``` +## Installation -### Throw `CustomError` (or subclass) -- Modules **must throw** an instance of `CustomError` (or a class extending it). -- This ensures errors can be consistently caught and appropriate actions taken. +Install with npm: -```ts -import { CustomError } from "@prefabs.tech/fastify-error-handler"; +```bash +npm install @prefabs.tech/fastify-error-handler +``` -const file = fileService.findById(1); +Install with pnpm: -if (!file) { - throw new CustomError("File not found", "FILE_NOT_FOUND_ERROR"); -} +```bash +pnpm add @prefabs.tech/fastify-error-handler ``` diff --git a/packages/error-handler/eslint.config.js b/packages/error-handler/eslint.config.js index 48a1291a4..7369a1f05 100644 --- a/packages/error-handler/eslint.config.js +++ b/packages/error-handler/eslint.config.js @@ -1,3 +1,22 @@ import fastifyConfig from "@prefabs.tech/eslint-config/fastify.js"; +import perfectionist from "eslint-plugin-perfectionist"; -export default fastifyConfig; +export default [ + ...fastifyConfig, + { + plugins: { + perfectionist, + }, + rules: { + // Disable conflicting default/import rules + "sort-imports": "off", + "import/order": "off", + + // Enable and spread Perfectionist's recommended rules + ...perfectionist.configs["recommended-alphabetical"].rules, + + // Add any Fastify-specific rule overrides here + "@typescript-eslint/no-explicit-any": "error", + }, + }, +]; diff --git a/packages/error-handler/package.json b/packages/error-handler/package.json index e9a74c251..e6f7a77ca 100644 --- a/packages/error-handler/package.json +++ b/packages/error-handler/package.json @@ -27,6 +27,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "sort-package": "npx sort-package-json", + "test": "vitest run --coverage", "typecheck": "tsc --noEmit -p tsconfig.json --composite false" }, "dependencies": { @@ -40,6 +41,7 @@ "@types/stack-trace": "0.0.33", "@vitest/coverage-istanbul": "3.2.4", "eslint": "9.39.2", + "eslint-plugin-perfectionist": "5.8.0", "fastify": "5.7.4", "fastify-plugin": "5.1.0", "prettier": "3.8.1", diff --git a/packages/error-handler/src/__test__/errorHandlerExport.test.ts b/packages/error-handler/src/__test__/errorHandlerExport.test.ts new file mode 100644 index 000000000..31f8d159e --- /dev/null +++ b/packages/error-handler/src/__test__/errorHandlerExport.test.ts @@ -0,0 +1,32 @@ +/* istanbul ignore file */ +import fastifySensible from "@fastify/sensible"; +import Fastify, { type FastifyInstance } from "fastify"; +import { afterEach, describe, expect, it } from "vitest"; + +import { errorHandler } from "../index"; + +describe("errorHandler — standalone export", () => { + let fastify: FastifyInstance; + + afterEach(async () => { + await fastify.close(); + }); + + it("handles errors the same way as the plugin when wired with sensible and stackTrace", async () => { + fastify = Fastify({ logger: false }); + fastify.decorate("stackTrace", false); + await fastify.register(fastifySensible); + fastify.setErrorHandler((err, request, reply) => { + errorHandler(err, request, reply); + }); + fastify.get("/boom", async () => { + throw new Error("internal detail"); + }); + await fastify.ready(); + + const res = await fastify.inject({ method: "GET", url: "/boom" }); + expect(res.statusCode).toBe(500); + expect(res.json().message).toBe("Server error, please contact support"); + expect(res.json().code).toBe("INTERNAL_SERVER_ERROR"); + }); +}); diff --git a/packages/error-handler/src/__test__/errorHandling.test.ts b/packages/error-handler/src/__test__/errorHandling.test.ts new file mode 100644 index 000000000..1e516c7f4 --- /dev/null +++ b/packages/error-handler/src/__test__/errorHandling.test.ts @@ -0,0 +1,130 @@ +/* istanbul ignore file */ +import { describe, expect, it } from "vitest"; + +import { CustomError } from "../index"; +import { buildFastify } from "./helpers"; + +describe("errorHandlerPlugin — CustomError handling", () => { + it("responds with 500 for CustomError", async () => { + const fastify = await buildFastify(); + fastify.get("/test", async () => { + throw new CustomError("internal failure", "MY_CODE"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(500); + await fastify.close(); + }); + + it("sanitizes message in response when stackTrace: false", async () => { + const fastify = await buildFastify({ stackTrace: false }); + fastify.get("/test", async () => { + throw new CustomError("secret DB credentials leaked", "MY_CODE"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().message).not.toContain("secret DB credentials"); + await fastify.close(); + }); + + it("sanitizes code and name in response when stackTrace: false", async () => { + const fastify = await buildFastify({ stackTrace: false }); + fastify.get("/test", async () => { + throw new CustomError("some error", "MY_ERROR_CODE"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().code).toBe("INTERNAL_SERVER_ERROR"); + expect(res.json().name).toBe("Error"); + await fastify.close(); + }); + + it("includes code in response when stackTrace: true", async () => { + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + throw new CustomError("some error", "MY_ERROR_CODE"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().code).toBe("MY_ERROR_CODE"); + await fastify.close(); + }); + + it("uses INTERNAL_SERVER_ERROR for CustomError when no code is set and stackTrace: true", async () => { + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + throw new CustomError("some error"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().code).toBe("INTERNAL_SERVER_ERROR"); + await fastify.close(); + }); + + it("includes name in response when stackTrace: true", async () => { + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + throw new CustomError("some error", "CODE"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().name).toBe("CustomError"); + await fastify.close(); + }); + + it("CustomError subclass is handled the same way", async () => { + class DatabaseError extends CustomError { + constructor(message: string) { + super(message, "DATABASE_ERROR"); + } + } + + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + throw new DatabaseError("connection timeout"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(500); + expect(res.json().code).toBe("DATABASE_ERROR"); + expect(res.json().name).toBe("DatabaseError"); + await fastify.close(); + }); +}); + +describe("errorHandlerPlugin — unknown error handling", () => { + it("responds with 500 for a plain Error", async () => { + const fastify = await buildFastify(); + fastify.get("/test", async () => { + throw new Error("unexpected crash"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(500); + await fastify.close(); + }); + + it("sanitizes the message for plain Error when stackTrace: false", async () => { + const fastify = await buildFastify({ stackTrace: false }); + fastify.get("/test", async () => { + throw new Error("secret internal detail"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().message).not.toContain("secret internal detail"); + await fastify.close(); + }); + + it("sanitizes code and name for plain Error when stackTrace: false", async () => { + const fastify = await buildFastify({ stackTrace: false }); + fastify.get("/test", async () => { + throw new Error("unexpected crash"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().code).toBe("INTERNAL_SERVER_ERROR"); + expect(res.json().name).toBe("Error"); + await fastify.close(); + }); + + it("includes stack and original message when stackTrace: true", async () => { + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + throw new Error("raw error message"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().message).toBe("raw error message"); + expect(Array.isArray(res.json().stack)).toBe(true); + await fastify.close(); + }); +}); diff --git a/packages/error-handler/src/__test__/helpers.ts b/packages/error-handler/src/__test__/helpers.ts new file mode 100644 index 000000000..b9f8bba2e --- /dev/null +++ b/packages/error-handler/src/__test__/helpers.ts @@ -0,0 +1,42 @@ +import Fastify from "fastify"; +import { MockInstance, vi } from "vitest"; + +import type { ErrorHandlerOptions } from "../index"; + +import errorHandlerPlugin from "../index"; + +export type FastifyInstance = ReturnType; + +export interface LogSpy { + child: MockInstance; + debug: MockInstance; + error: MockInstance; + fatal: MockInstance; + info: MockInstance; + level: string; + silent: MockInstance; + trace: MockInstance; + warn: MockInstance; +} + +export async function buildFastify(options: ErrorHandlerOptions = {}) { + const fastify = Fastify({ logger: false }); + await fastify.register(errorHandlerPlugin, options); + return fastify; +} + +export function makeLogSpy(): LogSpy { + const spy: LogSpy = { + child: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + info: vi.fn(), + level: "trace", + silent: vi.fn(), + trace: vi.fn(), + warn: vi.fn(), + }; + spy.child.mockImplementation(() => spy); + return spy; +} diff --git a/packages/error-handler/src/__test__/httpErrors.test.ts b/packages/error-handler/src/__test__/httpErrors.test.ts new file mode 100644 index 000000000..d65899fbf --- /dev/null +++ b/packages/error-handler/src/__test__/httpErrors.test.ts @@ -0,0 +1,76 @@ +/* istanbul ignore file */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildFastify, FastifyInstance } from "./helpers"; + +describe("errorHandlerPlugin — HTTP error methods", () => { + let fastify: FastifyInstance; + + beforeEach(async () => { + fastify = await buildFastify(); + fastify.get("/400", async () => { + throw fastify.httpErrors.badRequest("Invalid input"); + }); + fastify.get("/500", async () => { + throw fastify.httpErrors.internalServerError("Something went wrong"); + }); + }); + + afterEach(async () => await fastify.close()); + + it("badRequest → 400", async () => { + const res = await fastify.inject({ method: "GET", url: "/400" }); + expect(res.statusCode).toBe(400); + }); + + it("internalServerError → 500", async () => { + const res = await fastify.inject({ method: "GET", url: "/500" }); + expect(res.statusCode).toBe(500); + }); +}); + +describe("errorHandlerPlugin — error response structure", () => { + let fastify: FastifyInstance; + + beforeEach(async () => { + fastify = await buildFastify(); + fastify.get("/not-found", async () => { + throw fastify.httpErrors.notFound("User not found"); + }); + }); + + afterEach(async () => await fastify.close()); + + it("includes statusCode in the response body", async () => { + const res = await fastify.inject({ method: "GET", url: "/not-found" }); + expect(res.json().statusCode).toBe(404); + }); + + it("includes message in the response body", async () => { + const res = await fastify.inject({ method: "GET", url: "/not-found" }); + expect(res.json().message).toBe("User not found"); + }); + + it("includes name in the response body", async () => { + const res = await fastify.inject({ method: "GET", url: "/not-found" }); + expect(typeof res.json().name).toBe("string"); + expect(res.json().name.length).toBeGreaterThan(0); + }); + + it("includes error (HTTP status text) in the response body", async () => { + const res = await fastify.inject({ method: "GET", url: "/not-found" }); + expect(res.json().error).toBe("Not Found"); + }); + + it("code is absent for standard httpErrors helpers", async () => { + // @fastify/sensible v6 does NOT auto-populate .code on its helpers. + // Code only appears when the HttpError has an explicit .code set. + const res = await fastify.inject({ method: "GET", url: "/not-found" }); + expect(res.json().code).toBeUndefined(); + }); + + it("does not include stack by default (stackTrace: false)", async () => { + const res = await fastify.inject({ method: "GET", url: "/not-found" }); + expect(res.json().stack).toBeUndefined(); + }); +}); diff --git a/packages/error-handler/src/__test__/logging.test.ts b/packages/error-handler/src/__test__/logging.test.ts new file mode 100644 index 000000000..8a13c90a7 --- /dev/null +++ b/packages/error-handler/src/__test__/logging.test.ts @@ -0,0 +1,60 @@ +/* istanbul ignore file */ +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import errorHandlerPlugin from "../index"; +import { FastifyInstance, makeLogSpy } from "./helpers"; + +describe("errorHandlerPlugin — error logging", () => { + let fastify: FastifyInstance; + let logSpy: ReturnType; + + beforeEach(async () => { + logSpy = makeLogSpy(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fastify = Fastify({ loggerInstance: logSpy as any }); + await fastify.register(errorHandlerPlugin, {}); + + fastify.get("/4xx", async () => { + throw fastify.httpErrors.badRequest("bad input"); + }); + fastify.get("/5xx", async () => { + throw fastify.httpErrors.internalServerError("server error"); + }); + fastify.get("/3xx", async () => { + const error = new Error("redirect error") as { + statusCode: number; + } & Error; + error.statusCode = 302; + Object.setPrototypeOf( + error, + fastify.httpErrors.internalServerError().constructor.prototype, + ); + throw error; + }); + + await fastify.ready(); + vi.clearAllMocks(); + logSpy.child.mockImplementation(() => logSpy); + }); + + afterEach(async () => await fastify.close()); + + it("logs 4xx errors at info level", async () => { + await fastify.inject({ method: "GET", url: "/4xx" }); + expect(logSpy.info).toHaveBeenCalledWith(expect.any(Error)); + expect(logSpy.error).not.toHaveBeenCalledWith(expect.any(Error)); + }); + + it("logs 5xx errors at error level", async () => { + await fastify.inject({ method: "GET", url: "/5xx" }); + expect(logSpy.error).toHaveBeenCalledWith(expect.any(Error)); + expect(logSpy.info).not.toHaveBeenCalledWith(expect.any(Error)); + }); + + it("logs sub-400 HTTP errors at error level", async () => { + await fastify.inject({ method: "GET", url: "/3xx" }); + expect(logSpy.error).toHaveBeenCalledWith(expect.any(Error)); + expect(logSpy.info).not.toHaveBeenCalledWith(expect.any(Error)); + }); +}); diff --git a/packages/error-handler/src/__test__/masking.test.ts b/packages/error-handler/src/__test__/masking.test.ts new file mode 100644 index 000000000..68260019a --- /dev/null +++ b/packages/error-handler/src/__test__/masking.test.ts @@ -0,0 +1,105 @@ +/* istanbul ignore file */ +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import errorHandlerPlugin, { CustomError } from "../index"; +import { buildFastify, FastifyInstance, makeLogSpy } from "./helpers"; + +describe("errorHandlerPlugin — exact masked messages (stackTrace: false)", () => { + it("plain Error message is replaced with generic safe message", async () => { + const fastify = await buildFastify({ stackTrace: false }); + fastify.get("/test", async () => { + throw new Error("secret details here"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().message).toBe("Server error, please contact support"); + await fastify.close(); + }); + + it("CustomError message is replaced with a distinct safe message", async () => { + const fastify = await buildFastify({ stackTrace: false }); + fastify.get("/test", async () => { + throw new CustomError("secret internal state", "MY_CODE"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().message).toBe( + "Server has an error that is not handled, please contact support", + ); + await fastify.close(); + }); +}); + +describe("errorHandlerPlugin — unknown error normalization", () => { + it("coerces a thrown non-Error value into a 500 response", async () => { + const fastify = await buildFastify(); + fastify.get("/test", async () => { + throw undefined; + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(500); + await fastify.close(); + }); + + it("coerces a thrown string into Error UNKNOWN_ERROR before masking", async () => { + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + throw "not an Error instance"; + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(500); + expect(res.json().message).toBe("UNKNOWN_ERROR"); + await fastify.close(); + }); + + it("coerces a thrown null into a generic 500 when stackTrace is false", async () => { + const fastify = await buildFastify({ stackTrace: false }); + fastify.get("/test", async () => { + // eslint-disable-next-line unicorn/no-null -- explicit null throw is part of the normalization behavior being tested + throw null; + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(500); + expect(res.json().message).toBe("Server error, please contact support"); + await fastify.close(); + }); +}); + +describe("errorHandlerPlugin — non-HttpError logging", () => { + let fastify: FastifyInstance; + let logSpy: ReturnType; + + beforeEach(async () => { + logSpy = makeLogSpy(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fastify = Fastify({ loggerInstance: logSpy as any }); + await fastify.register(errorHandlerPlugin, {}); + fastify.get("/test", async () => { + throw new Error("unexpected crash"); + }); + await fastify.ready(); + vi.clearAllMocks(); + logSpy.child.mockImplementation(() => logSpy); + }); + + afterEach(async () => await fastify.close()); + + it("plain Error is always logged at error level regardless of stackTrace setting", async () => { + await fastify.inject({ method: "GET", url: "/test" }); + expect(logSpy.error).toHaveBeenCalledWith(expect.any(Error)); + expect(logSpy.info).not.toHaveBeenCalledWith(expect.any(Error)); + }); +}); + +describe("errorHandlerPlugin — stack trace gated on error.stack presence", () => { + it("omits stack field when error has no .stack property, even with stackTrace: true", async () => { + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + const err = new Error("no stack"); + delete err.stack; + throw err; + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().stack).toBeUndefined(); + await fastify.close(); + }); +}); diff --git a/packages/error-handler/src/__test__/preErrorHandler.test.ts b/packages/error-handler/src/__test__/preErrorHandler.test.ts new file mode 100644 index 000000000..3101d3403 --- /dev/null +++ b/packages/error-handler/src/__test__/preErrorHandler.test.ts @@ -0,0 +1,94 @@ +/* istanbul ignore file */ +import { describe, expect, it, vi } from "vitest"; + +import { buildFastify } from "./helpers"; + +describe("errorHandlerPlugin — preErrorHandler", () => { + it("is called before the default error handler", async () => { + const preErrorHandler = vi.fn(); + const fastify = await buildFastify({ preErrorHandler }); + + fastify.get("/test", async () => { + throw fastify.httpErrors.notFound("missing"); + }); + + await fastify.inject({ method: "GET", url: "/test" }); + + expect(preErrorHandler).toHaveBeenCalledOnce(); + await fastify.close(); + }); + + it("receives error, request, and reply as arguments", async () => { + let capturedArguments: unknown[] = []; + + const fastify = await buildFastify({ + preErrorHandler: async (error, request, reply) => { + capturedArguments = [error, request, reply]; + }, + }); + + fastify.get("/test", async () => { + throw fastify.httpErrors.badRequest("bad"); + }); + + await fastify.inject({ method: "GET", url: "/test" }); + + expect(capturedArguments[0]).toBeInstanceOf(Error); + expect(capturedArguments[1]).toHaveProperty("method"); + expect(capturedArguments[2]).toHaveProperty("send"); + await fastify.close(); + }); + + it("skips default handler when preErrorHandler sends the reply", async () => { + const customPayload = { custom: "handled" }; + + const fastify = await buildFastify({ + preErrorHandler: async (_error, _request, reply) => { + await reply.code(200).send(customPayload); + }, + }); + + fastify.get("/test", async () => { + throw fastify.httpErrors.internalServerError("boom"); + }); + + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual(customPayload); + await fastify.close(); + }); + + it("falls through to the default handler when preErrorHandler throws", async () => { + const fastify = await buildFastify({ + preErrorHandler: async () => { + throw new Error("preErrorHandler crashed"); + }, + }); + + fastify.get("/test", async () => { + throw fastify.httpErrors.badRequest("bad input"); + }); + + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(400); + expect(res.json().message).toBe("bad input"); + await fastify.close(); + }); + + it("default handler still runs when preErrorHandler does nothing", async () => { + const fastify = await buildFastify({ + preErrorHandler: async () => { + // intentionally empty + }, + }); + + fastify.get("/test", async () => { + throw fastify.httpErrors.notFound("missing resource"); + }); + + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(404); + expect(res.json().message).toBe("missing resource"); + await fastify.close(); + }); +}); diff --git a/packages/error-handler/src/__test__/registration.test.ts b/packages/error-handler/src/__test__/registration.test.ts new file mode 100644 index 000000000..f8fc57a95 --- /dev/null +++ b/packages/error-handler/src/__test__/registration.test.ts @@ -0,0 +1,45 @@ +/* istanbul ignore file */ +import Fastify from "fastify"; +import { describe, expect, it } from "vitest"; + +import errorHandlerPlugin from "../index"; +import { buildFastify } from "./helpers"; + +describe("errorHandlerPlugin — registration", () => { + it("registers without throwing", async () => { + const fastify = Fastify({ logger: false }); + await expect( + fastify.register(errorHandlerPlugin, {}), + ).resolves.not.toThrow(); + await fastify.close(); + }); + + it("decorates fastify with stackTrace: false by default", async () => { + const fastify = await buildFastify(); + await fastify.ready(); + expect(fastify.stackTrace).toBe(false); + await fastify.close(); + }); + + it("decorates fastify with stackTrace: true when option is set", async () => { + const fastify = await buildFastify({ stackTrace: true }); + await fastify.ready(); + expect(fastify.stackTrace).toBe(true); + await fastify.close(); + }); + + it("registers the ErrorResponse JSON schema", async () => { + const fastify = await buildFastify(); + await fastify.ready(); + expect(fastify.getSchema("ErrorResponse")).toBeDefined(); + await fastify.close(); + }); + + it("registers @fastify/sensible helpers on the fastify instance", async () => { + const fastify = await buildFastify(); + await fastify.ready(); + expect(fastify.httpErrors).toBeDefined(); + expect(typeof fastify.httpErrors.badRequest).toBe("function"); + await fastify.close(); + }); +}); diff --git a/packages/error-handler/src/__test__/stackTrace.test.ts b/packages/error-handler/src/__test__/stackTrace.test.ts new file mode 100644 index 000000000..147cc7e67 --- /dev/null +++ b/packages/error-handler/src/__test__/stackTrace.test.ts @@ -0,0 +1,47 @@ +/* istanbul ignore file */ +import { describe, expect, it } from "vitest"; + +import { buildFastify } from "./helpers"; + +describe("errorHandlerPlugin — stack trace option", () => { + it("omits stack from response when stackTrace: false", async () => { + const fastify = await buildFastify({ stackTrace: false }); + fastify.get("/test", async () => { + throw fastify.httpErrors.internalServerError("boom"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().stack).toBeUndefined(); + await fastify.close(); + }); + + it("includes stack array in response when stackTrace: true (5xx)", async () => { + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + throw fastify.httpErrors.internalServerError("boom"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(Array.isArray(res.json().stack)).toBe(true); + expect(res.json().stack.length).toBeGreaterThan(0); + await fastify.close(); + }); + + it("includes stack array in response when stackTrace: true (4xx)", async () => { + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + throw fastify.httpErrors.badRequest("bad"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(Array.isArray(res.json().stack)).toBe(true); + await fastify.close(); + }); + + it("each stack entry has file and line information", async () => { + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + throw fastify.httpErrors.internalServerError("boom"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().stack[0]).toHaveProperty("line"); + await fastify.close(); + }); +}); diff --git a/packages/error-handler/src/errorHandler.ts b/packages/error-handler/src/errorHandler.ts index de2a21c87..503dc6e7c 100644 --- a/packages/error-handler/src/errorHandler.ts +++ b/packages/error-handler/src/errorHandler.ts @@ -1,13 +1,12 @@ -import { STATUS_CODES } from "node:http"; - import { HttpError } from "@fastify/sensible"; import { FastifyReply, FastifyRequest } from "fastify"; +import { STATUS_CODES } from "node:http"; import StackTracey from "stacktracey"; -import { CustomError } from "./utils/error"; - import type { ErrorResponse } from "./types"; +import { CustomError } from "./utils/error"; + const getHttpStatusText = (statusCode: number): string => STATUS_CODES[statusCode] ?? "Internal Server Error"; @@ -55,34 +54,26 @@ export const errorHandler = ( return; } - let message = "Server error, please contact support"; let code = "INTERNAL_SERVER_ERROR"; + let message = "Server error, please contact support"; if (error instanceof CustomError) { code = error.code || code; message = "Server has an error that is not handled, please contact support"; } - if (isStackTraceEnabled && error.stack) { - const response: ErrorResponse = { - code: code, - message: error.message, - name: error.name, - statusCode: 500, - stack: stack.items, - }; + const response: ErrorResponse = { + code: isStackTraceEnabled ? code : "INTERNAL_SERVER_ERROR", + message: isStackTraceEnabled ? error.message : message, + name: isStackTraceEnabled ? error.name : "Error", + statusCode: 500, + }; - logger.error(error); - - void reply.code(500).send(response); - - return; + if (isStackTraceEnabled && error.stack) { + response.stack = stack.items; } - // remove stack and message from error - delete error.stack; - error.message = message; + logger.error(error); - // let fastify handle the error - throw error; + void reply.code(500).send(response); }; diff --git a/packages/error-handler/src/index.ts b/packages/error-handler/src/index.ts index 791fa67d8..99309e470 100644 --- a/packages/error-handler/src/index.ts +++ b/packages/error-handler/src/index.ts @@ -7,14 +7,14 @@ declare module "fastify" { } } +export { errorHandler } from "./errorHandler"; + export { default } from "./plugin"; -export { errorHandler } from "./errorHandler"; +export type * from "./types"; export { CustomError } from "./utils/error"; export type { HttpErrors } from "@fastify/sensible"; export { default as StackTracey } from "stacktracey"; - -export type * from "./types"; diff --git a/packages/error-handler/src/plugin.ts b/packages/error-handler/src/plugin.ts index ff3e0829d..7b81c80bc 100644 --- a/packages/error-handler/src/plugin.ts +++ b/packages/error-handler/src/plugin.ts @@ -1,12 +1,13 @@ +import type { FastifyInstance } from "fastify"; + import fastifySensible from "@fastify/sensible"; import FastifyPlugin from "fastify-plugin"; +import type { ErrorHandlerOptions } from "./types"; + import { errorHandler } from "./errorHandler"; import { errorSchema } from "./utils/errorSchema"; -import type { ErrorHandlerOptions } from "./types"; -import type { FastifyInstance } from "fastify"; - const plugin = async ( fastify: FastifyInstance, options: ErrorHandlerOptions, diff --git a/packages/error-handler/src/types.ts b/packages/error-handler/src/types.ts index 7caefe738..44eeb115a 100644 --- a/packages/error-handler/src/types.ts +++ b/packages/error-handler/src/types.ts @@ -1,11 +1,11 @@ -import { FastifyRequest, FastifyReply } from "fastify"; +import { FastifyReply, FastifyRequest } from "fastify"; import StackTracey from "stacktracey"; type ErrorHandler = ( error: unknown, request: FastifyRequest, reply: FastifyReply, -) => void | Promise; +) => Promise | void; interface ErrorHandlerOptions { preErrorHandler?: ErrorHandler; @@ -13,8 +13,8 @@ interface ErrorHandlerOptions { } type ErrorResponse = { - error?: string; code?: string; + error?: string; message: string; name: string; stack?: StackTracey.Entry[]; diff --git a/packages/error-handler/src/utils/errorSchema.ts b/packages/error-handler/src/utils/errorSchema.ts index a5711a8b4..941d6c0fa 100644 --- a/packages/error-handler/src/utils/errorSchema.ts +++ b/packages/error-handler/src/utils/errorSchema.ts @@ -1,19 +1,19 @@ export const errorSchema = { $id: "ErrorResponse", - type: "object", + additionalProperties: true, properties: { code: { type: "string" }, error: { type: "string" }, message: { type: "string" }, name: { type: "string" }, stack: { - type: "array", items: { - type: "object", additionalProperties: true, + type: "object", }, + type: "array", }, statusCode: { type: "number" }, }, - additionalProperties: true, + type: "object", }; diff --git a/packages/error-handler/tsconfig.json b/packages/error-handler/tsconfig.json index 50005d55b..1628077b9 100644 --- a/packages/error-handler/tsconfig.json +++ b/packages/error-handler/tsconfig.json @@ -1,9 +1,9 @@ { "extends": "@prefabs.tech/tsconfig/fastify.json", + "exclude": ["src/**/__test__/**/*"], "compilerOptions": { - "outDir": "./dist", + "baseUrl": "./", + "outDir": "./dist" }, - "include": [ - "src/**/*.ts" - ] + "include": ["src/**/*.ts"] } diff --git a/packages/error-handler/vite.config.ts b/packages/error-handler/vite.config.ts index c830a4392..3373c356e 100644 --- a/packages/error-handler/vite.config.ts +++ b/packages/error-handler/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { dependencies, peerDependencies } from "./package.json"; diff --git a/packages/firebase/FEATURES.md b/packages/firebase/FEATURES.md new file mode 100644 index 000000000..052bd759a --- /dev/null +++ b/packages/firebase/FEATURES.md @@ -0,0 +1,110 @@ + + +# @prefabs.tech/fastify-firebase — Features + +## Plugin Lifecycle + +1. **Enable/disable via config flag** — when `config.firebase.enabled === false`, Firebase initialization and database migrations are skipped entirely. Routes are still registered unless individually disabled. + +2. **Automatic database migrations** — on registration (when enabled), runs `CREATE TABLE IF NOT EXISTS` for the user devices table, including a composite index on `(user_id, device_token)`. + +3. **Firebase initialization with private key normalization** — initializes `firebase-admin` using credentials from `config.firebase.credentials`, replacing literal `\n` escape sequences in `privateKey` with actual newlines before passing to the SDK. + +4. **Skip re-initialization guard** — if `admin.apps.length > 0`, `initializeFirebase` returns early without calling `admin.initializeApp` again. + +5. **Missing credentials guard** — when `enabled !== false` and `config.firebase.credentials` is absent, logs an error and returns instead of throwing. + +## Route Registration + +6. **Conditional userDevice routes** — POST `/user-device` and DELETE `/user-device` routes are registered by default; set `config.firebase.routes.userDevices.disabled = true` to skip registration entirely. + +7. **Conditional notification test route** — the test-notification route is only registered when `config.firebase.notification.test.enabled === true`. + +8. **Configurable route prefix** — all routes are registered under `config.firebase.routePrefix`. + +9. **Configurable notification test path** — the notification test route uses `config.firebase.notification.test.path` when set, falling back to the constant `/send-notification`. + +10. **Custom handler overrides** — default route handlers can be replaced per-handler via config: + ```typescript + config.firebase.handlers = { + notification: { sendNotification: myNotificationHandler }, + userDevice: { + addUserDevice: myAddHandler, + removeUserDevice: myRemoveHandler, + }, + }; + ``` + +## Middleware + +11. **`isFirebaseEnabled` preHandler** — a reusable preHandler factory that throws a `404 notFound` error when `config.firebase.enabled === false`; applied to all firebase routes automatically. + +## HTTP Route Handlers + +12. **`POST /user-device` — register a device** — requires an authenticated session (`verifySession`); throws `401 unauthorized` when `request.user` is absent; creates a device record linked to the authenticated user's ID. + +13. **`DELETE /user-device` — remove a device** — requires an authenticated session; throws `401` when unauthenticated, `404` when the user has no registered devices, `422` when the device is not owned by the requesting user. + +14. **`POST ` — test push notification** — only registered when `notification.test.enabled = true`; requires authentication; throws `422` when the receiver has no registered devices; sends a multicast FCM message with Android high-priority and APNS sound settings included. + +## Database + +15. **Configurable user devices table name** — `config.firebase.table.userDevices.name` overrides the default table name `user_devices` at both migration time and query time. + +16. **`UserDeviceService.getByUserId`** — queries all device records for a given `userId`. + +17. **`UserDeviceService.removeByDeviceToken`** — deletes a device record by token (returning the deleted row). + +18. **Device token deduplication on create** — `UserDeviceService.preCreate` calls `removeByDeviceToken` before inserting, ensuring each device token is stored only once regardless of which user previously registered it. + +## GraphQL + +19. **`firebaseSchema` export** — a merged GraphQL type-definitions document combining the base schema, notification schema, and user device schema; ready to pass to mercurius. + +20. **`sendNotification` GraphQL mutation** — `@auth`-protected; returns `401` when unauthenticated, `404` when firebase is disabled, `400` when `userId` is missing, `404` when the receiver has no registered devices, `500` on unexpected errors. + +21. **`addUserDevice` GraphQL mutation** — `@auth`-protected; returns `404` when firebase is disabled, `401` when unauthenticated, `500` on unexpected errors. + +22. **`removeUserDevice` GraphQL mutation** — `@auth`-protected; returns `404` when firebase is disabled, `401` when unauthenticated, `403` when the user has no registered devices, `403` when the device is not owned by the requesting user. + +## Utility Functions + +23. **`sendPushNotification`** — thin wrapper around `firebase-admin` `messaging().sendEachForMulticast(message)`; accepts a `MulticastMessage` and returns a promise. + +24. **`initializeFirebase`** — exported utility that initializes the `firebase-admin` app from `ApiConfig`; handles private key normalization, re-init guard, and credential-missing guard. + +## Module Augmentations + +25. **`FastifyInstance.verifySession`** — declares `verifySession` (from `supertokens-node`) on the Fastify instance interface. + +26. **`FastifyRequest.user`** — declares an optional `user: User` property on all Fastify requests. + +27. **`MercuriusContext.user`** — declares a required `user: User` property on the Mercurius context interface. + +28. **`ApiConfig.firebase`** — extends `@prefabs.tech/fastify-config`'s `ApiConfig` with the full `firebase` configuration block (credentials, enabled, handlers, notification, routePrefix, routes, table). + +## Type Exports + +29. **`User`** — `{ id: string }`. + +30. **`UserDevice`** — `{ userId, deviceToken, createdAt, updatedAt }`. + +31. **`UserDeviceCreateInput`** — partial of `UserDevice` excluding timestamps. + +32. **`UserDeviceUpdateInput`** — partial of `UserDevice` excluding timestamps and `userId`. + +33. **`TestNotificationInput`** — `{ userId, title, body, data? }`. + +## Constants + +34. **Exported route and table constants** — `ROUTE_SEND_NOTIFICATION` (`/send-notification`), `ROUTE_USER_DEVICE_ADD` (`/user-device`), `ROUTE_USER_DEVICE_REMOVE` (`/user-device`), `TABLE_USER_DEVICES` (`user_devices`). + +## Migration Queries + +35. **`createUserDevicesTableQuery` export** — exported SQL factory function for the user devices table DDL; uses the configured or default table name. + +## Initialization and Route Guards + +36. **Initialization failure logging** — if `firebase-admin` initialization throws, `initializeFirebase` catches the error and logs both a fixed message (`"Failed to initialize firebase"`) and the original error object instead of crashing startup. + +37. **Explicit notification route disable flag** — even when `config.firebase.notification.test.enabled === true`, setting `config.firebase.routes.notifications.disabled = true` prevents notification route registration entirely. diff --git a/packages/firebase/GUIDE.md b/packages/firebase/GUIDE.md new file mode 100644 index 000000000..89b6559cb --- /dev/null +++ b/packages/firebase/GUIDE.md @@ -0,0 +1,649 @@ +# @prefabs.tech/fastify-firebase — Developer Guide + +## Installation + +### For package consumers (npm + pnpm) + +```bash +# npm +npm install @prefabs.tech/fastify-firebase firebase-admin fastify fastify-plugin + +# pnpm +pnpm add @prefabs.tech/fastify-firebase firebase-admin fastify fastify-plugin +``` + +Peer dependencies that must also be installed: + +```bash +pnpm add @prefabs.tech/fastify-config \ + @prefabs.tech/fastify-error-handler \ + @prefabs.tech/fastify-graphql \ + @prefabs.tech/fastify-slonik \ + mercurius \ + slonik \ + supertokens-node +``` + +### For monorepo development (pnpm install / test / build) + +```bash +# From the repo root — install all workspaces +pnpm install + +# Run tests for this package only +pnpm --filter @prefabs.tech/fastify-firebase test + +# Build +pnpm --filter @prefabs.tech/fastify-firebase build +``` + +## Setup + +Register the plugin once. All later examples assume this configuration is in place. + +```typescript +import Fastify from "fastify"; +import firebasePlugin from "@prefabs.tech/fastify-firebase"; + +// @prefabs.tech/fastify-config, @prefabs.tech/fastify-slonik, and +// @prefabs.tech/fastify-error-handler must be registered before this plugin. +const app = Fastify(); + +await app.register(firebasePlugin); + +// The plugin reads all settings from app.config.firebase (injected by +// @prefabs.tech/fastify-config). A minimal configuration looks like: +// +// config.firebase = { +// enabled: true, +// credentials: { +// clientEmail: process.env.FIREBASE_CLIENT_EMAIL!, +// privateKey: process.env.FIREBASE_PRIVATE_KEY!, // \n escapes are normalized automatically +// projectId: process.env.FIREBASE_PROJECT_ID!, +// }, +// routePrefix: "/api", +// }; +``` + +--- + +## Base Libraries + +### `firebase-admin` — Partial Passthrough + +**Their docs:** https://www.npmjs.com/package/firebase-admin + +We initialize `firebase-admin` internally via `initializeFirebase` and expose a single wrapper (`sendPushNotification`) over `messaging().sendEachForMulticast`. The rest of the `firebase-admin` surface area (Auth, Firestore, Storage, etc.) is not wrapped; call `firebase-admin` directly in your application code for those services. + +What we add on top: + +- Private-key `\n` normalization before calling `admin.initializeApp`. +- Re-initialization guard (`admin.apps.length > 0`). +- Missing-credentials guard with structured error logging instead of a thrown exception. +- `sendPushNotification` — a typed async wrapper around multicast messaging. + +### `supertokens-node` — Partial Passthrough + +**Their docs:** https://www.npmjs.com/package/supertokens-node + +We use `verifySession` from `supertokens-node/recipe/session/framework/fastify` as a preHandler on every route. We do not wrap or re-export the supertokens initialization; you must configure SuperTokens in your application before registering this plugin. + +What we add on top: + +- `FastifyInstance.verifySession` module augmentation so the decorator is typed everywhere. +- `FastifyRequest.user` module augmentation (`{ id: string }`) populated by your application's session middleware. + +### `fastify-plugin` — Full Passthrough + +**Their docs:** https://www.npmjs.com/package/fastify-plugin + +Used internally to ensure the plugin does not create a new Fastify scope (decorators registered by peer plugins remain visible). Not re-exported. + +### `slonik` — Partial Passthrough + +**Their docs:** https://www.npmjs.com/package/slonik + +Used internally for all database access via `@prefabs.tech/fastify-slonik`'s `BaseService` / `DefaultSqlFactory`. We expose `createUserDevicesTableQuery` for consumers who manage their own migration pipeline. + +### `mercurius` — Partial Passthrough + +**Their docs:** https://www.npmjs.com/package/mercurius + +GraphQL resolvers use `mercurius.ErrorWithProps` for structured error responses and read `MercuriusContext`. We add a `MercuriusContext.user` augmentation and export `firebaseSchema`, `notificationResolver`, and `userDeviceResolver` for consumption by your Mercurius setup. + +--- + +## Features + +### 1. Enable / disable via config flag + +Set `config.firebase.enabled = false` to skip Firebase initialization and database migrations while keeping the plugin registered. Routes are still registered unless also disabled. + +```typescript +// config.firebase.enabled = false +// → initializeFirebase is NOT called +// → runMigrations is NOT called +// → routes are registered but all respond with 404 (isFirebaseEnabled preHandler) +``` + +### 2. Automatic database migrations + +On plugin registration (when `enabled !== false`) the plugin runs `CREATE TABLE IF NOT EXISTS user_devices` with a composite index on `(user_id, device_token)`. No manual migration step is needed. + +```typescript +// Nothing to call — happens automatically inside plugin registration. +// To inspect the generated SQL, import the query factory directly: +import { createUserDevicesTableQuery } from "@prefabs.tech/fastify-firebase"; + +const query = createUserDevicesTableQuery(config); +// query is a Slonik QuerySqlToken ready to execute +``` + +### 3. Firebase initialization with private key normalization + +`initializeFirebase` replaces literal `\n` in `privateKey` with actual newline characters before calling `admin.initializeApp`. This allows the raw value from an environment variable (where newlines are often stored as `\n`) to be passed directly. + +```typescript +import { initializeFirebase } from "@prefabs.tech/fastify-firebase"; + +// Called automatically by the plugin, but can also be called manually: +initializeFirebase(config, fastify); +// If admin.apps.length > 0, this is a no-op. +// If credentials are missing, logs an error and returns without throwing. +// If admin.initializeApp throws, the function logs the failure and the error object. +``` + +### 4. Skip re-initialization guard + +`initializeFirebase` checks `admin.apps.length > 0` and returns early if Firebase is already initialized. This makes the function safe to call multiple times in test environments or multi-registration scenarios. + +### 5. Missing credentials guard + +If `enabled !== false` and `config.firebase.credentials` is `undefined`, `initializeFirebase` logs `"Firebase credentials are missing"` at the error level and returns without throwing, preventing an uncaught exception during startup. + +### 6. Conditional userDevice routes + +POST `/user-device` and DELETE `/user-device` are registered by default. Disable them: + +```typescript +// In your ApiConfig: +config.firebase.routes = { + userDevices: { disabled: true }, +}; +``` + +### 7. Conditional notification test route + +The test-notification route is only registered when explicitly enabled: + +```typescript +config.firebase.notification = { + test: { + enabled: true, + path: "/test/send-notification", // optional; defaults to /send-notification + }, +}; + +// Optional hard-disable switch (wins even when test.enabled is true): +config.firebase.routes = { + notifications: { disabled: true }, +}; +``` + +### 8. Configurable route prefix + +All routes are mounted under the prefix you configure: + +```typescript +config.firebase.routePrefix = "/api/v1"; +// Results in: POST /api/v1/user-device, DELETE /api/v1/user-device, etc. +``` + +### 9. Configurable notification test path + +When the notification test route is enabled, its path defaults to `/send-notification` but can be overridden: + +```typescript +config.firebase.notification = { + test: { + enabled: true, + path: "/internal/push-test", + }, +}; +``` + +### 10. Custom handler overrides + +Replace any default route handler with your own implementation: + +```typescript +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + +const myAddHandler = async (request: SessionRequest, reply: FastifyReply) => { + // custom logic + reply.send({ ok: true }); +}; + +config.firebase.handlers = { + userDevice: { + addUserDevice: myAddHandler, + removeUserDevice: myRemoveHandler, + }, + notification: { + sendNotification: myNotificationHandler, + }, +}; +``` + +### 11. `isFirebaseEnabled` preHandler + +A preHandler factory that throws a Fastify `404 notFound` error if `config.firebase.enabled === false`. It is applied automatically to all firebase routes by this package. + +```typescript +// No extra setup required. Once the plugin is registered: +config.firebase.enabled = false; + +// Firebase-managed routes then return 404: +// POST /user-device +// DELETE /user-device +// POST /send-notification (when enabled in config) +``` + +### 12. `POST /user-device` — register a device token + +Requires a valid SuperTokens session. Associates the authenticated user's ID with a device token. Deduplicates automatically — if the token is already registered to another user, it is removed first. + +```typescript +// POST /user-device +// Headers: Cookie: sAccessToken=... +// Body: +{ "deviceToken": "fcm-token-abc123" } + +// 200 response: +{ + "userId": "user-uuid", + "deviceToken": "fcm-token-abc123", + "createdAt": 1712345678, + "updatedAt": 1712345678 +} +// 401 — unauthenticated +// 404 — firebase disabled +``` + +### 13. `DELETE /user-device` — remove a device token + +Requires authentication. Validates that the device token belongs to the requesting user before deleting. + +```typescript +// DELETE /user-device +// Headers: Cookie: sAccessToken=... +// Body: +{ "deviceToken": "fcm-token-abc123" } + +// 200 — returns the deleted UserDevice record (or null) +// 401 — unauthenticated +// 404 — user has no registered devices +// 422 — device not owned by requesting user +// 404 — firebase disabled +``` + +### 14. `POST ` — test push notification + +Only registered when `config.firebase.notification.test.enabled = true`. Sends a multicast FCM message with Android high-priority and APNS default-sound settings to all devices registered for the target user. + +```typescript +// POST /send-notification (or your configured test path) +// Headers: Cookie: sAccessToken=... +// Body: +{ + "userId": "target-user-uuid", + "title": "Hello", + "message": "World", +} + +// 200: { "message": "Notification sent successfully" } +// 401 — unauthenticated +// 422 — receiver has no registered devices +// 404 — firebase disabled +``` + +### 15. Configurable user devices table name + +Override the default `user_devices` table name for both migrations and all queries: + +```typescript +config.firebase.table = { + userDevices: { name: "custom_user_devices" }, +}; +``` + +### 16 & 17. `UserDeviceService.getByUserId` / `removeByDeviceToken` + +```typescript +import { UserDeviceService } from "@prefabs.tech/fastify-firebase"; + +const service = new UserDeviceService(config, database, dbSchema); + +// Get all devices for a user +const devices = await service.getByUserId("user-uuid"); +// → UserDevice[] | undefined + +// Remove a device by token (returns the deleted row) +const removed = await service.removeByDeviceToken("fcm-token-abc123"); +// → UserDevice | undefined +``` + +### 18. Device token deduplication on create + +`UserDeviceService.create` (inherited from `BaseService`) calls `preCreate` before inserting. `preCreate` removes any existing row with the same `deviceToken`. This means each FCM token can only be associated with one user at a time. + +```typescript +const service = new UserDeviceService(config, database, dbSchema); + +// If "fcm-token-abc" is already registered to user A, this call first +// deletes that row then inserts a new one for user B: +await service.create({ deviceToken: "fcm-token-abc", userId: "user-b" }); +``` + +### 19. `firebaseSchema` export + +A merged GraphQL SDL document combining the base schema, notification types, and user device types. Pass it to Mercurius alongside your resolvers. + +```typescript +import { + firebaseSchema, + notificationResolver, + userDeviceResolver, +} from "@prefabs.tech/fastify-firebase"; +import { mergeResolvers } from "@prefabs.tech/fastify-graphql"; + +// In your Mercurius setup: +await app.register(mercurius, { + schema: firebaseSchema, + resolvers: mergeResolvers([notificationResolver, userDeviceResolver]), +}); +``` + +### 20. `sendNotification` GraphQL mutation + +```graphql +mutation { + sendNotification( + data: { + userId: "target-user-uuid" + title: "New message" + body: "You have a new message" + } + ) { + message + } +} +``` + +Error codes returned as `mercurius.ErrorWithProps`: + +- `401` — unauthenticated (`context.user` is absent) +- `404` — firebase disabled +- `400` — `userId` not provided +- `404` — receiver has no registered devices +- `500` — unexpected error + +### 21. `addUserDevice` GraphQL mutation + +```graphql +mutation { + addUserDevice(data: { deviceToken: "fcm-token-xyz" }) { + id + userId + deviceToken + createdAt + updatedAt + } +} +``` + +Error codes: `404` firebase disabled, `401` unauthenticated, `500` unexpected. + +### 22. `removeUserDevice` GraphQL mutation + +```graphql +mutation { + removeUserDevice(data: { deviceToken: "fcm-token-xyz" }) { + id + userId + deviceToken + } +} +``` + +Error codes: `404` firebase disabled, `401` unauthenticated, `403` user has no devices, `403` device not owned by user. + +### 23. `sendPushNotification` utility + +Sends a Firebase multicast message. Accepts the full `MulticastMessage` type from `firebase-admin`. + +```typescript +import { sendPushNotification } from "@prefabs.tech/fastify-firebase"; +import type { MulticastMessage } from "firebase-admin/lib/messaging/messaging-api"; + +const message: MulticastMessage = { + tokens: ["token-a", "token-b"], + notification: { title: "Alert", body: "Something happened" }, + data: { orderId: "42" }, +}; + +await sendPushNotification(message); +``` + +### 24. `initializeFirebase` utility + +```typescript +import { initializeFirebase } from "@prefabs.tech/fastify-firebase"; + +initializeFirebase(config, fastify); +// No-op if already initialized. +// Logs error (does not throw) if credentials are missing. +``` + +### 25–28. Module augmentations + +The package extends four interfaces automatically on import. No action needed — these give you type safety throughout your application: + +```typescript +import "@prefabs.tech/fastify-firebase"; // augmentations applied on import + +// fastify.verifySession is now typed +// request.user is now typed as User | undefined +// MercuriusContext.user is now typed as User +// ApiConfig.firebase is now typed with all config options +``` + +### 29–33. Type exports + +```typescript +import type { + User, + UserDevice, + UserDeviceCreateInput, + UserDeviceUpdateInput, + TestNotificationInput, +} from "@prefabs.tech/fastify-firebase"; +``` + +| Type | Shape | +| ----------------------- | ------------------------------------------------------------------- | +| `User` | `{ id: string }` | +| `UserDevice` | `{ userId, deviceToken, createdAt: number, updatedAt: number }` | +| `UserDeviceCreateInput` | `Partial>` | +| `UserDeviceUpdateInput` | `Partial>` | +| `TestNotificationInput` | `{ userId, title, body, data?: Record }` | + +### 34. Route and table constants + +```typescript +import { + ROUTE_SEND_NOTIFICATION, // "/send-notification" + ROUTE_USER_DEVICE_ADD, // "/user-device" + ROUTE_USER_DEVICE_REMOVE, // "/user-device" + TABLE_USER_DEVICES, // "user_devices" +} from "@prefabs.tech/fastify-firebase"; +``` + +### 35. `createUserDevicesTableQuery` export + +```typescript +import { createUserDevicesTableQuery } from "@prefabs.tech/fastify-firebase"; + +const query = createUserDevicesTableQuery(config); +// Returns a Slonik QuerySqlToken for the user_devices DDL. +// Respects config.firebase.table?.userDevices?.name. +``` + +--- + +## Use Cases + +### Use case 1: Register the plugin and enable FCM push notifications + +Full end-to-end setup for a Fastify app that accepts device registrations and sends notifications via REST. + +```typescript +import Fastify from "fastify"; +import configPlugin from "@prefabs.tech/fastify-config"; +import errorHandlerPlugin from "@prefabs.tech/fastify-error-handler"; +import slonikPlugin from "@prefabs.tech/fastify-slonik"; +import firebasePlugin from "@prefabs.tech/fastify-firebase"; + +const app = Fastify({ logger: true }); + +await app.register(configPlugin); +await app.register(errorHandlerPlugin); +await app.register(slonikPlugin); +await app.register(firebasePlugin); + +// config.firebase is sourced from ApiConfig (e.g. env vars via @prefabs.tech/fastify-config): +// { +// enabled: true, +// credentials: { +// clientEmail: "svc@project.iam.gserviceaccount.com", +// privateKey: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", +// projectId: "my-firebase-project", +// }, +// routePrefix: "/api", +// } + +await app.listen({ port: 3000 }); +// Routes now active: +// POST /api/user-device +// DELETE /api/user-device +``` + +### Use case 2: Enable GraphQL mutations for notifications and device management + +```typescript +import mercurius from "mercurius"; +import { + firebaseSchema, + notificationResolver, + userDeviceResolver, +} from "@prefabs.tech/fastify-firebase"; +import { mergeResolvers } from "@prefabs.tech/fastify-graphql"; + +await app.register(mercurius, { + schema: firebaseSchema, + resolvers: mergeResolvers([notificationResolver, userDeviceResolver]), + context: (request) => ({ + user: request.user, // populated by your session middleware + config: app.config, + database: app.slonik, + dbSchema: app.dbSchema, + app, + }), +}); +``` + +### Use case 3: Send a push notification from application code + +```typescript +import { + UserDeviceService, + sendPushNotification, +} from "@prefabs.tech/fastify-firebase"; +import type { MulticastMessage } from "firebase-admin/lib/messaging/messaging-api"; + +async function notifyUser( + config: ApiConfig, + database: Database, + dbSchema: string, + userId: string, + title: string, + body: string, +) { + const service = new UserDeviceService(config, database, dbSchema); + const devices = await service.getByUserId(userId); + + if (!devices || devices.length === 0) { + return; // user has no registered devices + } + + const message: MulticastMessage = { + tokens: devices.map((d) => d.deviceToken), + notification: { title, body }, + }; + + await sendPushNotification(message); +} +``` + +### Use case 4: Disable specific routes and override a handler + +```typescript +// Disable device routes; use only GraphQL for device management. +// Override the notification handler with custom logic. +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + +const customSendNotification = async ( + request: SessionRequest, + reply: FastifyReply, +) => { + // custom auditing, rate limiting, etc. + const { userId, title, message } = request.body as { + userId: string; + title: string; + message: string; + }; + // ... custom logic ... + reply.send({ success: true, message: "sent" }); +}; + +// In your ApiConfig: +// config.firebase.routes = { userDevices: { disabled: true } }; +// config.firebase.handlers = { notification: { sendNotification: customSendNotification } }; +``` + +### Use case 5: Run with Firebase disabled (feature flag) + +Keep the plugin registered but prevent all Firebase activity (useful for environments without Firebase credentials): + +```typescript +// config.firebase.enabled = false + +// Result: +// - initializeFirebase → skipped (no credentials needed) +// - runMigrations → skipped +// - All firebase routes are registered but respond with 404 +// - GraphQL mutations return 404 +// - sendPushNotification will fail if called directly (no firebase app) +``` + +### Use case 6: Use a custom table name + +```typescript +// config.firebase.table = { userDevices: { name: "mobile_push_devices" } }; +// - Migration creates "mobile_push_devices" table (not "user_devices") +// - All UserDeviceService queries target "mobile_push_devices" +// - ROUTE_USER_DEVICE_ADD / TABLE_USER_DEVICES constants are unaffected +// (they reflect defaults; the runtime table name comes from config) +``` diff --git a/packages/firebase/README.md b/packages/firebase/README.md index 989758d41..996312c1b 100644 --- a/packages/firebase/README.md +++ b/packages/firebase/README.md @@ -2,23 +2,41 @@ A [Fastify](https://github.com/fastify/fastify) plugin that provides an easy integration of Firebase Admin in a fastify API. +## Why this plugin? + +Integrating Firebase Admin into a Node.js API typically involves much more than just calling `initializeApp()`. To support features like push notifications securely, you must manage user device tokens in a database, expose REST routes or GraphQL mutations to clients to register those devices, and define secure dispatch handlers. We created this plugin to: + +- **Provide a Complete Feature Slice**: Rather than just wrapping the SDK, this plugin provides a fully functioning User Device and Notification management system out-of-the-box, automatically taking advantage of your `@prefabs.tech/fastify-slonik` database setup. +- **Bootstrap APIs Instantly**: It automatically provides and wires up both REST routes and GraphQL resolvers/schemas (`userDevice`, `notification`) so you don't have to manually write the boilerplate to add, remove, and manage FCM tokens across your applications. +- **Centralize Configuration**: By extending our `@prefabs.tech/fastify-config` interface, we ensure that your Firebase credentials, database table preferences, and route configurations are strictly typed and managed in one central place alongside the rest of your app. +- **Allow Clean Overrides**: While we provide default controllers and services for handling devices and notifications, the plugin architecture allows you to easily override them via the config (`config.firebase.handlers`) whenever your business logic requires custom behavior. + ## Requirements -* [@prefabs.tech/fastify-config](../config/) -* [@prefabs.tech/fastify-slonik](../slonik/) +Peer dependencies (install compatible versions — see [package.json](./package.json)): + +- [@prefabs.tech/fastify-config](../config/) +- [@prefabs.tech/fastify-error-handler](../error-handler/) +- [@prefabs.tech/fastify-graphql](../graphql/) +- [@prefabs.tech/fastify-slonik](../slonik/) +- [`fastify`](https://www.npmjs.com/package/fastify) +- [`fastify-plugin`](https://www.npmjs.com/package/fastify-plugin) +- [`mercurius`](https://www.npmjs.com/package/mercurius) +- [`slonik`](https://www.npmjs.com/package/slonik) +- [`supertokens-node`](https://www.npmjs.com/package/supertokens-node) ## Installation Install with npm: ```bash -npm install @prefabs.tech/fastify-config @prefabs.tech/fastify-slonik @prefabs.tech/fastify-firebase +npm install @prefabs.tech/fastify-config @prefabs.tech/fastify-error-handler @prefabs.tech/fastify-graphql @prefabs.tech/fastify-slonik @prefabs.tech/fastify-firebase fastify fastify-plugin mercurius slonik supertokens-node ``` Install with pnpm: ```bash -pnpm add --filter "@scope/project" @prefabs.tech/fastify-config @prefabs.tech/fastify-slonik @prefabs.tech/fastify-firebase +pnpm add --filter "@scope/project" @prefabs.tech/fastify-config @prefabs.tech/fastify-error-handler @prefabs.tech/fastify-graphql @prefabs.tech/fastify-slonik @prefabs.tech/fastify-firebase fastify fastify-plugin mercurius slonik supertokens-node ``` ## Usage @@ -28,32 +46,35 @@ pnpm add --filter "@scope/project" @prefabs.tech/fastify-config @prefabs.tech/fa Register the fastify-firebase plugin with your Fastify instance: ```typescript -import firebasePlugin from "@prefabs.tech/fastify-firebase"; import configPlugin from "@prefabs.tech/fastify-config"; +import errorHandlerPlugin from "@prefabs.tech/fastify-error-handler"; +import firebasePlugin from "@prefabs.tech/fastify-firebase"; +import graphqlPlugin from "@prefabs.tech/fastify-graphql"; +import slonikPlugin from "@prefabs.tech/fastify-slonik"; import Fastify from "fastify"; import config from "./config"; import type { ApiConfig } from "@prefabs.tech/fastify-config"; -import type { FastifyInstance } from "fastify"; const start = async () => { - // Create fastify instance const fastify = Fastify({ logger: config.logger, }); - // Register fastify-config plugin await fastify.register(configPlugin, { config }); - - // Register fastify-firebase plugin + await fastify.register(errorHandlerPlugin, { + stackTrace: process.env.NODE_ENV === "development", + }); + await fastify.register(slonikPlugin, config.slonik); + await fastify.register(graphqlPlugin, config.graphql); await fastify.register(firebasePlugin); await fastify.listen({ - port: config.port, host: "0.0.0.0", + port: config.port, }); -} +}; start(); ``` @@ -107,7 +128,7 @@ To load and merge this schema with your application's custom schemas, update you ```typescript import { firebaseSchema } from "@prefabs.tech/fastify-firebase"; import { loadFilesSync } from "@graphql-tools/load-files"; -import { mergeTypeDefs } from ""; +import { mergeTypeDefs } from "@graphql-tools/merge"; import { makeExecutableSchema } from "@graphql-tools/schema"; const schemas: string[] = loadFilesSync("./src/**/*.gql"); @@ -123,7 +144,10 @@ export default schema; To integrate the resolvers provided by this package, import them and merge with your application's resolvers: ```typescript -import { notificationResolver, userDeviceResolver } from "@prefabs.tech/fastify-firebase"; +import { + notificationResolver, + userDeviceResolver, +} from "@prefabs.tech/fastify-firebase"; import type { IResolvers } from "mercurius"; diff --git a/packages/firebase/__test__/service.spec.ts b/packages/firebase/__test__/service.spec.ts deleted file mode 100644 index 213d49642..000000000 --- a/packages/firebase/__test__/service.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* istanbul ignore file */ -import admin from "firebase-admin"; -import { describe, expect, it } from "vitest"; - -describe("Firebase Service", () => { - it("Should initialize firebase-admin without errors", () => { - expect(admin).toBeDefined(); - expect(admin.initializeApp).toBeDefined(); - expect(admin.messaging).toBeDefined(); - }); - - it("Should have messaging API available", () => { - expect(typeof admin.messaging).toBe("function"); - }); -}); diff --git a/packages/firebase/eslint.config.js b/packages/firebase/eslint.config.js index 48a1291a4..7369a1f05 100644 --- a/packages/firebase/eslint.config.js +++ b/packages/firebase/eslint.config.js @@ -1,3 +1,22 @@ import fastifyConfig from "@prefabs.tech/eslint-config/fastify.js"; +import perfectionist from "eslint-plugin-perfectionist"; -export default fastifyConfig; +export default [ + ...fastifyConfig, + { + plugins: { + perfectionist, + }, + rules: { + // Disable conflicting default/import rules + "sort-imports": "off", + "import/order": "off", + + // Enable and spread Perfectionist's recommended rules + ...perfectionist.configs["recommended-alphabetical"].rules, + + // Add any Fastify-specific rule overrides here + "@typescript-eslint/no-explicit-any": "error", + }, + }, +]; diff --git a/packages/firebase/package.json b/packages/firebase/package.json index 3c0dc846f..46bb3be5a 100644 --- a/packages/firebase/package.json +++ b/packages/firebase/package.json @@ -19,7 +19,9 @@ "main": "./dist/prefabs-tech-fastify-firebase.cjs", "module": "./dist/prefabs-tech-fastify-firebase.js", "types": "./dist/types/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", @@ -42,10 +44,12 @@ "@types/node": "24.10.13", "@vitest/coverage-istanbul": "3.2.4", "eslint": "9.39.2", + "eslint-plugin-perfectionist": "5.8.0", "fastify": "5.7.4", "fastify-plugin": "5.1.0", "graphql": "16.12.0", "mercurius": "16.7.0", + "pg-mem": "3.0.14", "prettier": "3.8.1", "slonik": "46.8.0", "supertokens-node": "14.1.4", diff --git a/packages/firebase/src/__test__/controllers.test.ts b/packages/firebase/src/__test__/controllers.test.ts new file mode 100644 index 000000000..e6172efeb --- /dev/null +++ b/packages/firebase/src/__test__/controllers.test.ts @@ -0,0 +1,134 @@ +import type { FastifyInstance } from "fastify"; + +/* istanbul ignore file */ +import Fastify from "fastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ROUTE_SEND_NOTIFICATION, + ROUTE_USER_DEVICE_ADD, + ROUTE_USER_DEVICE_REMOVE, +} from "../constants"; + +// The route schemas reference "ErrorResponse#" which is registered by the error-handler plugin. +// We add it directly here so the test instance can resolve the $ref. +const errorResponseSchema = { + $id: "ErrorResponse", + additionalProperties: true, + properties: { + code: { type: "string" }, + error: { type: "string" }, + message: { type: "string" }, + statusCode: { type: "number" }, + }, + type: "object", +}; + +const mockVerifySession = async () => {}; + +const buildFastify = (firebaseConfig: Record = {}) => { + const fastify = Fastify({ logger: false }); + + fastify.addSchema(errorResponseSchema); + fastify.decorate("config", { + firebase: { + enabled: true, + ...firebaseConfig, + }, + }); + fastify.decorate("verifySession", () => mockVerifySession); + fastify.decorate("httpErrors", { + notFound: (message: string) => + Object.assign(new Error(message), { statusCode: 404 }), + unauthorized: (message: string) => + Object.assign(new Error(message), { statusCode: 401 }), + }); + + return fastify; +}; + +describe("notification controller — custom handler overrides", async () => { + const { default: controller } = + await import("../model/notification/controller"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls custom sendNotification handler from config.firebase.handlers.notification", async () => { + const customHandler = vi.fn().mockImplementation(async (_req, reply) => { + await reply.send({ ok: true }); + }); + + fastify = buildFastify({ + handlers: { notification: { sendNotification: customHandler } }, + notification: { test: { enabled: true, path: ROUTE_SEND_NOTIFICATION } }, + }); + await fastify.register(controller); + await fastify.ready(); + + await fastify.inject({ + method: "POST", + payload: { message: "Hello", title: "Test", userId: "user-1" }, + url: ROUTE_SEND_NOTIFICATION, + }); + + expect(customHandler).toHaveBeenCalled(); + await fastify.close(); + }); +}); + +describe("userDevice controller — custom handler overrides", async () => { + const { default: controller } = + await import("../model/userDevice/controller"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls custom addUserDevice handler from config.firebase.handlers.userDevice", async () => { + const customHandler = vi.fn().mockImplementation(async (_req, reply) => { + await reply.send({ ok: true }); + }); + + fastify = buildFastify({ + handlers: { userDevice: { addUserDevice: customHandler } }, + }); + await fastify.register(controller); + await fastify.ready(); + + await fastify.inject({ + method: "POST", + payload: { deviceToken: "token-abc" }, + url: ROUTE_USER_DEVICE_ADD, + }); + + expect(customHandler).toHaveBeenCalled(); + await fastify.close(); + }); + + it("calls custom removeUserDevice handler from config.firebase.handlers.userDevice", async () => { + const customHandler = vi.fn().mockImplementation(async (_req, reply) => { + await reply.send({ ok: true }); + }); + + fastify = buildFastify({ + handlers: { userDevice: { removeUserDevice: customHandler } }, + }); + await fastify.register(controller); + await fastify.ready(); + + await fastify.inject({ + method: "DELETE", + payload: { deviceToken: "token-abc" }, + url: ROUTE_USER_DEVICE_REMOVE, + }); + + expect(customHandler).toHaveBeenCalled(); + await fastify.close(); + }); +}); diff --git a/packages/firebase/src/__test__/handlers.test.ts b/packages/firebase/src/__test__/handlers.test.ts new file mode 100644 index 000000000..3338f63b0 --- /dev/null +++ b/packages/firebase/src/__test__/handlers.test.ts @@ -0,0 +1,288 @@ +import type { FastifyInstance } from "fastify"; + +/* istanbul ignore file */ +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ROUTE_SEND_NOTIFICATION, + ROUTE_USER_DEVICE_ADD, + ROUTE_USER_DEVICE_REMOVE, +} from "../constants"; +import notificationController from "../model/notification/controller"; +import userDeviceController from "../model/userDevice/controller"; + +const { + mockCreate, + mockGetByUserId, + mockRemoveByDeviceToken, + sendPushNotificationMock, +} = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockGetByUserId: vi.fn(), + mockRemoveByDeviceToken: vi.fn(), + sendPushNotificationMock: vi.fn(), +})); + +vi.mock("../model/userDevice/service", () => ({ + default: vi.fn().mockImplementation(() => ({ + create: mockCreate, + getByUserId: mockGetByUserId, + removeByDeviceToken: mockRemoveByDeviceToken, + })), +})); + +vi.mock("../lib/sendPushNotification", () => ({ + default: sendPushNotificationMock, +})); + +const errorResponseSchema = { + $id: "ErrorResponse", + additionalProperties: true, + properties: { + code: { type: "string" }, + error: { type: "string" }, + message: { type: "string" }, + statusCode: { type: "number" }, + }, + type: "object", +}; + +type VerifySessionRequest = { + headers: { "x-user-id"?: string }; + user?: object; +}; + +const verifySession = async (request: VerifySessionRequest) => { + if (request.headers["x-user-id"]) { + request.user = { id: request.headers["x-user-id"] }; + } +}; + +const buildFastify = (firebaseConfig: Record = {}) => { + const fastify = Fastify({ logger: false }); + + fastify.addSchema(errorResponseSchema); + fastify.decorate("config", { + firebase: { + enabled: true, + notification: { + test: { + enabled: true, + path: ROUTE_SEND_NOTIFICATION, + }, + }, + ...firebaseConfig, + }, + }); + fastify.decorate("dbSchema", "public"); + fastify.decorate("slonik", { connect: vi.fn(), pool: {}, query: vi.fn() }); + fastify.decorate("httpErrors", { + notFound: (message: string) => + Object.assign(new Error(message), { statusCode: 404 }), + unauthorized: (message: string) => + Object.assign(new Error(message), { statusCode: 401 }), + unprocessableEntity: (message: string) => + Object.assign(new Error(message), { statusCode: 422 }), + }); + fastify.decorate("verifySession", () => verifySession); + + return fastify; +}; + +describe("firebase route handlers", () => { + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (fastify) { + await fastify.close(); + } + }); + + it("returns 401 for POST /user-device when request.user is missing", async () => { + fastify = buildFastify(); + await fastify.register(userDeviceController); + await fastify.ready(); + + const response = await fastify.inject({ + method: "POST", + payload: { deviceToken: "token-abc" }, + url: ROUTE_USER_DEVICE_ADD, + }); + + expect(response.statusCode).toBe(401); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it("creates a user-device record for authenticated POST /user-device", async () => { + mockCreate.mockResolvedValue({ + createdAt: 1, + deviceToken: "token-abc", + updatedAt: 1, + userId: "user-1", + }); + + fastify = buildFastify(); + await fastify.register(userDeviceController); + await fastify.ready(); + + const response = await fastify.inject({ + headers: { "x-user-id": "user-1" }, + method: "POST", + payload: { deviceToken: "token-abc" }, + url: ROUTE_USER_DEVICE_ADD, + }); + + expect(response.statusCode).toBe(200); + expect(mockCreate).toHaveBeenCalledWith({ + deviceToken: "token-abc", + userId: "user-1", + }); + }); + + it("returns 404 for DELETE /user-device when user has no devices", async () => { + mockGetByUserId.mockResolvedValue([]); + + fastify = buildFastify(); + await fastify.register(userDeviceController); + await fastify.ready(); + + const response = await fastify.inject({ + headers: { "x-user-id": "user-1" }, + method: "DELETE", + payload: { deviceToken: "token-abc" }, + url: ROUTE_USER_DEVICE_REMOVE, + }); + + expect(response.statusCode).toBe(404); + expect(mockRemoveByDeviceToken).not.toHaveBeenCalled(); + }); + + it("returns 422 for DELETE /user-device when token is not owned by user", async () => { + mockGetByUserId.mockResolvedValue([ + { + deviceToken: "different-token", + userId: "user-1", + }, + ]); + + fastify = buildFastify(); + await fastify.register(userDeviceController); + await fastify.ready(); + + const response = await fastify.inject({ + headers: { "x-user-id": "user-1" }, + method: "DELETE", + payload: { deviceToken: "token-abc" }, + url: ROUTE_USER_DEVICE_REMOVE, + }); + + expect(response.statusCode).toBe(422); + expect(mockRemoveByDeviceToken).not.toHaveBeenCalled(); + }); + + it("deletes device for authenticated DELETE /user-device when token is owned", async () => { + mockGetByUserId.mockResolvedValue([ + { + deviceToken: "token-abc", + userId: "user-1", + }, + ]); + mockRemoveByDeviceToken.mockResolvedValue({ + createdAt: 1, + deviceToken: "token-abc", + updatedAt: 1, + userId: "user-1", + }); + + fastify = buildFastify(); + await fastify.register(userDeviceController); + await fastify.ready(); + + const response = await fastify.inject({ + headers: { "x-user-id": "user-1" }, + method: "DELETE", + payload: { deviceToken: "token-abc" }, + url: ROUTE_USER_DEVICE_REMOVE, + }); + + expect(response.statusCode).toBe(200); + expect(mockRemoveByDeviceToken).toHaveBeenCalledWith("token-abc"); + }); + + it("returns 422 for POST /send-notification when receiver has no devices", async () => { + mockGetByUserId.mockResolvedValue([]); + + fastify = buildFastify(); + await fastify.register(notificationController); + await fastify.ready(); + + const response = await fastify.inject({ + headers: { "x-user-id": "sender-1" }, + method: "POST", + payload: { message: "Body", title: "Title", userId: "receiver-1" }, + url: ROUTE_SEND_NOTIFICATION, + }); + + expect(response.statusCode).toBe(422); + expect(sendPushNotificationMock).not.toHaveBeenCalled(); + }); + + it("sends push notification with android/apns defaults for valid POST /send-notification", async () => { + mockGetByUserId.mockResolvedValue([ + { + deviceToken: "token-a", + userId: "receiver-1", + }, + { + deviceToken: "token-b", + userId: "receiver-1", + }, + ]); + sendPushNotificationMock.mockResolvedValue(); + + fastify = buildFastify(); + await fastify.register(notificationController); + await fastify.ready(); + + const response = await fastify.inject({ + headers: { "x-user-id": "sender-1" }, + method: "POST", + payload: { + body: "Body", + data: { orderId: "42" }, + message: "Body", + title: "Title", + userId: "receiver-1", + }, + url: ROUTE_SEND_NOTIFICATION, + }); + + expect(response.statusCode).toBe(200); + expect(sendPushNotificationMock).toHaveBeenCalledWith({ + android: { + notification: { sound: "default" }, + priority: "high", + }, + apns: { + payload: { + aps: { sound: "default" }, + }, + }, + data: { + body: "Body", + orderId: "42", + title: "Title", + }, + notification: { + body: "Body", + title: "Title", + }, + tokens: ["token-a", "token-b"], + }); + }); +}); diff --git a/packages/firebase/src/__test__/helpers/createConfig.ts b/packages/firebase/src/__test__/helpers/createConfig.ts new file mode 100644 index 000000000..6d4ccd0f9 --- /dev/null +++ b/packages/firebase/src/__test__/helpers/createConfig.ts @@ -0,0 +1,32 @@ +/* istanbul ignore file */ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +const createConfig = ( + firebaseOverrides: Record = {}, +): ApiConfig => + ({ + appName: "app", + appOrigin: ["http://localhost"], + baseUrl: "http://localhost", + env: "development", + firebase: { + enabled: true, + ...firebaseOverrides, + }, + logger: { level: "debug" }, + name: "Test", + port: 3000, + protocol: "http", + rest: { enabled: true }, + slonik: { + db: { + databaseName: "test", + host: "localhost", + password: "password", + username: "username", + }, + }, + version: "0.1", + }) as unknown as ApiConfig; + +export default createConfig; diff --git a/packages/firebase/src/__test__/helpers/createDatabase.ts b/packages/firebase/src/__test__/helpers/createDatabase.ts new file mode 100644 index 000000000..33997794b --- /dev/null +++ b/packages/firebase/src/__test__/helpers/createDatabase.ts @@ -0,0 +1,56 @@ +import type { IMemoryDb, SlonikAdapterOptions } from "pg-mem"; +import type { Interceptor, QueryResultRow } from "slonik"; + +/* istanbul ignore file */ +import { newDb } from "pg-mem"; + +// Converts snake_case keys to camelCase — mirrors fieldNameCaseConverter from @prefabs.tech/fastify-slonik +const snakeToCamel = (s: string) => + s.replaceAll(/_([a-z])/g, (_, c: string) => c.toUpperCase()); + +const camelizeRow = (row: QueryResultRow): QueryResultRow => { + const result: QueryResultRow = {}; + for (const [key, value] of Object.entries(row)) { + result[snakeToCamel(key)] = value; + } + return result; +}; + +const fieldNameCaseConverter: Interceptor = { + transformRow: (_queryContext, _query, row): QueryResultRow => + camelizeRow(row), +}; + +interface Options { + db?: IMemoryDb; + slonikAdapterOptions?: SlonikAdapterOptions; +} + +const createDatabase = async (options?: Options) => { + const db = options?.db ?? newDb(); + + const defaultOptions: SlonikAdapterOptions = { + createPoolOptions: { + interceptors: [fieldNameCaseConverter], + }, + }; + + const mergedOptions: SlonikAdapterOptions = { + ...defaultOptions, + ...options?.slonikAdapterOptions, + createPoolOptions: { + ...defaultOptions.createPoolOptions, + ...options?.slonikAdapterOptions?.createPoolOptions, + }, + }; + + const pool = await db.adapters.createSlonik(mergedOptions); + + return { + connect: pool.connect.bind(pool), + pool, + query: pool.query.bind(pool), + }; +}; + +export default createDatabase; diff --git a/packages/firebase/src/__test__/initializeFirebase.test.ts b/packages/firebase/src/__test__/initializeFirebase.test.ts new file mode 100644 index 000000000..94a6b96d2 --- /dev/null +++ b/packages/firebase/src/__test__/initializeFirebase.test.ts @@ -0,0 +1,130 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { FastifyInstance } from "fastify"; + +/* istanbul ignore file */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// vi.hoisted ensures these are available inside the vi.mock factory +const { mockApps, mockCert, mockInitializeApp } = vi.hoisted(() => ({ + mockApps: [] as unknown[], + mockCert: vi.fn().mockReturnValue({ type: "service_account" }), + mockInitializeApp: vi.fn(), +})); + +vi.mock("firebase-admin", () => ({ + default: { + get apps() { + return mockApps; + }, + credential: { + cert: mockCert, + }, + initializeApp: mockInitializeApp, + }, +})); + +const baseCredentials = { + clientEmail: "test@test.iam.gserviceaccount.com", + privateKey: String.raw`-----BEGIN PRIVATE KEY-----\nKEY_DATA\nMORE_DATA\n-----END PRIVATE KEY-----`, + projectId: "test-project", +}; + +const makeConfig = (overrides: object = {}): ApiConfig => + ({ + firebase: { + credentials: baseCredentials, + enabled: true, + ...overrides, + }, + }) as unknown as ApiConfig; + +const makeFastify = () => + ({ + log: { + error: vi.fn(), + info: vi.fn(), + }, + }) as unknown as FastifyInstance; + +describe("initializeFirebase", async () => { + const { default: initializeFirebase } = + await import("../lib/initializeFirebase"); + + beforeEach(() => { + vi.clearAllMocks(); + mockApps.length = 0; + }); + + afterEach(() => { + mockApps.length = 0; + }); + + it("skips initialization when admin.apps is already populated", async () => { + mockApps.push({}); + const fastify = makeFastify(); + const config = makeConfig(); + + initializeFirebase(config, fastify); + + expect(mockInitializeApp).not.toHaveBeenCalled(); + }); + + it("logs an error when enabled is not false but credentials are missing", () => { + const fastify = makeFastify(); + const config = makeConfig({ credentials: undefined }); + + initializeFirebase(config, fastify); + + expect(fastify.log.error).toHaveBeenCalledWith( + "Firebase credentials are missing", + ); + expect(mockInitializeApp).not.toHaveBeenCalled(); + }); + + it( + String.raw`replaces literal \\n escape sequences in privateKey with real newlines`, + () => { + const fastify = makeFastify(); + const config = makeConfig(); + + initializeFirebase(config, fastify); + + expect(mockCert).toHaveBeenCalledWith( + expect.objectContaining({ + privateKey: expect.stringContaining("\n"), + }), + ); + const passedKey: string = mockCert.mock.calls[0][0].privateKey; + expect(passedKey).not.toContain(String.raw`\n`); + }, + ); + + it("passes projectId and clientEmail unchanged to admin.credential.cert", () => { + const fastify = makeFastify(); + const config = makeConfig(); + + initializeFirebase(config, fastify); + + expect(mockCert).toHaveBeenCalledWith( + expect.objectContaining({ + clientEmail: baseCredentials.clientEmail, + projectId: baseCredentials.projectId, + }), + ); + }); + + it("logs error and continues when admin.initializeApp throws", () => { + const error = new Error("initializeApp failed"); + mockInitializeApp.mockImplementationOnce(() => { + throw error; + }); + const fastify = makeFastify(); + const config = makeConfig(); + + expect(() => initializeFirebase(config, fastify)).not.toThrow(); + expect(fastify.log.error).toHaveBeenCalledWith( + "Failed to initialize firebase", + ); + expect(fastify.log.error).toHaveBeenCalledWith(error); + }); +}); diff --git a/packages/firebase/src/__test__/isFirebaseEnabled.test.ts b/packages/firebase/src/__test__/isFirebaseEnabled.test.ts new file mode 100644 index 000000000..8ff131416 --- /dev/null +++ b/packages/firebase/src/__test__/isFirebaseEnabled.test.ts @@ -0,0 +1,42 @@ +import type { FastifyInstance } from "fastify"; + +/* istanbul ignore file */ +import { describe, expect, it, vi } from "vitest"; + +import isFirebaseEnabled from "../middlewares/isFirebaseEnabled"; + +const makeFastify = (enabled?: boolean) => + ({ + config: { + firebase: { enabled }, + }, + httpErrors: { + notFound: vi.fn().mockReturnValue(new Error("Firebase is disabled")), + }, + }) as unknown as FastifyInstance; + +describe("isFirebaseEnabled", () => { + it("throws notFound when config.firebase.enabled === false", async () => { + const fastify = makeFastify(false); + const hook = isFirebaseEnabled(fastify); + + await expect(hook()).rejects.toThrow("Firebase is disabled"); + expect(fastify.httpErrors.notFound).toHaveBeenCalledWith( + "Firebase is disabled", + ); + }); + + it("resolves without throwing when config.firebase.enabled is undefined", async () => { + const fastify = makeFastify(); + const hook = isFirebaseEnabled(fastify); + + await expect(hook()).resolves.toBeUndefined(); + }); + + it("resolves without throwing when config.firebase.enabled === true", async () => { + const fastify = makeFastify(true); + const hook = isFirebaseEnabled(fastify); + + await expect(hook()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/firebase/src/__test__/libraryAndMigrations.test.ts b/packages/firebase/src/__test__/libraryAndMigrations.test.ts new file mode 100644 index 000000000..97d5aa306 --- /dev/null +++ b/packages/firebase/src/__test__/libraryAndMigrations.test.ts @@ -0,0 +1,79 @@ +/* istanbul ignore file */ +import { describe, expect, it, vi } from "vitest"; + +const { + createUserDevicesTableQueryMock, + messagingMock, + mockQueryToken, + sendEachForMulticastMock, +} = vi.hoisted(() => ({ + createUserDevicesTableQueryMock: vi.fn(), + messagingMock: vi.fn(), + mockQueryToken: { sql: "SELECT 1", type: "SLONIK_TOKEN" }, + sendEachForMulticastMock: vi.fn(), +})); + +vi.mock("../migrations/queries", () => ({ + createUserDevicesTableQuery: createUserDevicesTableQueryMock, +})); + +vi.mock("firebase-admin", () => ({ + default: { + messaging: messagingMock, + }, +})); + +describe("runMigrations", async () => { + const { default: runMigrations } = + await import("../migrations/runMigrations"); + + it("executes createUserDevicesTableQuery inside a database connection", async () => { + createUserDevicesTableQueryMock.mockReturnValue(mockQueryToken); + + const query = vi.fn().mockResolvedValue(); + const connect = vi.fn().mockImplementation(async (handler) => { + await handler({ query }); + }); + + const database = { + connect, + }; + const config = { + firebase: { enabled: true }, + }; + + await runMigrations( + database as Parameters[0], + config as Parameters[1], + ); + + expect(createUserDevicesTableQueryMock).toHaveBeenCalledWith(config); + expect(connect).toHaveBeenCalledOnce(); + expect(query).toHaveBeenCalledWith(mockQueryToken); + }); +}); + +describe("sendPushNotification", async () => { + const { default: sendPushNotification } = + await import("../lib/sendPushNotification"); + + it("forwards multicast messages to firebase-admin messaging", async () => { + sendEachForMulticastMock.mockResolvedValue(); + messagingMock.mockReturnValue({ + sendEachForMulticast: sendEachForMulticastMock, + }); + + const message = { + notification: { + body: "Body", + title: "Title", + }, + tokens: ["token-a", "token-b"], + }; + + await sendPushNotification(message); + + expect(messagingMock).toHaveBeenCalledOnce(); + expect(sendEachForMulticastMock).toHaveBeenCalledWith(message); + }); +}); diff --git a/packages/firebase/src/__test__/notificationResolver.test.ts b/packages/firebase/src/__test__/notificationResolver.test.ts new file mode 100644 index 000000000..1d97c35d0 --- /dev/null +++ b/packages/firebase/src/__test__/notificationResolver.test.ts @@ -0,0 +1,121 @@ +import type { MercuriusContext } from "mercurius"; + +/* istanbul ignore file */ +import { mercurius } from "mercurius"; +import { describe, expect, it, vi } from "vitest"; + +import notificationResolver from "../model/notification/graphql/resolver"; + +// vi.hoisted ensures these are available inside the vi.mock factory (which is hoisted) +const { sendPushNotificationMock } = vi.hoisted(() => ({ + sendPushNotificationMock: vi.fn().mockImplementation(async () => {}), +})); + +vi.mock("../lib/sendPushNotification", () => ({ + default: sendPushNotificationMock, +})); + +vi.mock("../model/userDevice/service", () => ({ + default: vi.fn().mockImplementation(() => ({ + getByUserId: vi + .fn() + .mockResolvedValue([{ deviceToken: "token-abc", userId: "user-1" }]), + })), +})); + +const makeContext = ( + overrides: Partial = {}, +): MercuriusContext => + ({ + app: { log: { error: vi.fn() } }, + config: { firebase: { enabled: true } }, + database: {}, + dbSchema: "", + user: { id: "sender-1" }, + ...overrides, + }) as unknown as MercuriusContext; + +const arguments_ = { + data: { + body: "World", + data: {}, + title: "Hello", + userId: "user-1", + }, +}; + +describe("notificationResolver.sendNotification", () => { + it("returns 401 ErrorWithProps when user is not in context", async () => { + const context = makeContext({ user: undefined }); + const result = await notificationResolver.Mutation.sendNotification( + undefined, + arguments_, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(401); + }); + + it("returns 404 ErrorWithProps when firebase is disabled", async () => { + const context = makeContext({ + config: { + firebase: { enabled: false }, + } as unknown as MercuriusContext["config"], + }); + const result = await notificationResolver.Mutation.sendNotification( + undefined, + arguments_, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(404); + }); + + it("returns 400 ErrorWithProps when userId is missing in args", async () => { + const context = makeContext(); + const argumentsWithoutUserId = { data: { ...arguments_.data, userId: "" } }; + const result = await notificationResolver.Mutation.sendNotification( + undefined, + argumentsWithoutUserId, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(400); + }); + + it("returns 404 ErrorWithProps when receiver has no registered devices", async () => { + const { default: UserDeviceService } = + await import("../model/userDevice/service"); + vi.mocked(UserDeviceService).mockImplementationOnce( + () => + ({ + getByUserId: vi.fn().mockResolvedValue([]), + }) as unknown as ReturnType, + ); + + const context = makeContext(); + const result = await notificationResolver.Mutation.sendNotification( + undefined, + arguments_, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(404); + }); + + it("calls sendPushNotification and returns success message when all conditions met", async () => { + const context = makeContext(); + const result = await notificationResolver.Mutation.sendNotification( + undefined, + arguments_, + context, + ); + + expect(sendPushNotificationMock).toHaveBeenCalled(); + expect(result).toEqual({ message: "Notification sent successfully" }); + }); +}); diff --git a/packages/firebase/src/__test__/plugin.test.ts b/packages/firebase/src/__test__/plugin.test.ts new file mode 100644 index 000000000..88ac0623e --- /dev/null +++ b/packages/firebase/src/__test__/plugin.test.ts @@ -0,0 +1,296 @@ +import type { FastifyInstance } from "fastify"; + +/* istanbul ignore file */ +import Fastify from "fastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ROUTE_SEND_NOTIFICATION, + ROUTE_USER_DEVICE_ADD, + ROUTE_USER_DEVICE_REMOVE, +} from "../constants"; + +// The route schemas reference "ErrorResponse#" which is registered by the error-handler plugin. +// We add it directly here so the test instance can resolve the $ref. +const errorResponseSchema = { + $id: "ErrorResponse", + additionalProperties: true, + properties: { + code: { type: "string" }, + error: { type: "string" }, + message: { type: "string" }, + statusCode: { type: "number" }, + }, + type: "object", +}; + +const runMigrationsMock = vi.fn().mockResolvedValue(); +const initializeFirebaseMock = vi.fn(); + +vi.mock("../migrations/runMigrations", () => ({ + default: runMigrationsMock, +})); + +vi.mock("../lib/initializeFirebase", () => ({ + default: initializeFirebaseMock, +})); + +const mockSlonik = { connect: vi.fn(), pool: {}, query: vi.fn() }; +const mockVerifySession = async () => {}; + +/** + * Builds a Fastify instance decorated with all the dependencies the firebase + * plugin reads from the fastify instance (config, slonik, verifySession, httpErrors). + */ +const buildFastify = (firebaseConfig: Record = {}) => { + const fastify = Fastify({ logger: false }); + + fastify.addSchema(errorResponseSchema); + fastify.decorate("config", { + firebase: { + enabled: true, + routePrefix: "/api", + ...firebaseConfig, + }, + }); + fastify.decorate("slonik", mockSlonik); + // verifySession is called at route registration time to produce a preHandler + fastify.decorate("verifySession", () => mockVerifySession); + fastify.decorate("httpErrors", { + notFound: (message: string) => + Object.assign(new Error(message), { statusCode: 404 }), + unauthorized: (message: string) => + Object.assign(new Error(message), { statusCode: 401 }), + }); + + return fastify; +}; + +describe("firebasePlugin — initialization", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not call runMigrations when enabled === false", async () => { + fastify = buildFastify({ enabled: false }); + await fastify.register(plugin); + await fastify.ready(); + + expect(runMigrationsMock).not.toHaveBeenCalled(); + await fastify.close(); + }); + + it("does not call initializeFirebase when enabled === false", async () => { + fastify = buildFastify({ enabled: false }); + await fastify.register(plugin); + await fastify.ready(); + + expect(initializeFirebaseMock).not.toHaveBeenCalled(); + await fastify.close(); + }); + + it("calls runMigrations when enabled is not false", async () => { + fastify = buildFastify({ enabled: true }); + await fastify.register(plugin); + await fastify.ready(); + + expect(runMigrationsMock).toHaveBeenCalledOnce(); + await fastify.close(); + }); + + it("calls initializeFirebase when enabled is not false", async () => { + fastify = buildFastify({ enabled: true }); + await fastify.register(plugin); + await fastify.ready(); + + expect(initializeFirebaseMock).toHaveBeenCalledOnce(); + await fastify.close(); + }); + + it("passes slonik and config to runMigrations", async () => { + fastify = buildFastify({ enabled: true }); + await fastify.register(plugin); + await fastify.ready(); + + expect(runMigrationsMock).toHaveBeenCalledWith( + mockSlonik, + expect.objectContaining({ firebase: expect.any(Object) }), + ); + await fastify.close(); + }); +}); + +describe("firebasePlugin — userDevice route registration", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("registers POST /user-device route by default", async () => { + fastify = buildFastify({ enabled: false }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ + method: "POST", + url: `${fastify.config.firebase.routePrefix}${ROUTE_USER_DEVICE_ADD}`, + }), + ).toBe(true); + await fastify.close(); + }); + + it("registers DELETE /user-device route by default", async () => { + fastify = buildFastify({ enabled: false }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ + method: "DELETE", + url: `${fastify.config.firebase.routePrefix}${ROUTE_USER_DEVICE_REMOVE}`, + }), + ).toBe(true); + await fastify.close(); + }); + + it("skips userDevice routes when routes.userDevices.disabled === true", async () => { + fastify = buildFastify({ + enabled: false, + routes: { userDevices: { disabled: true } }, + }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ + method: "POST", + url: `${fastify.config.firebase.routePrefix}${ROUTE_USER_DEVICE_ADD}`, + }), + ).toBe(false); + await fastify.close(); + }); + + it("registers user device routes under a custom routePrefix", async () => { + const customPrefix = "/v2/firebase"; + fastify = buildFastify({ enabled: false, routePrefix: customPrefix }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ + method: "POST", + url: `${customPrefix}${ROUTE_USER_DEVICE_ADD}`, + }), + ).toBe(true); + + expect( + fastify.hasRoute({ + method: "DELETE", + url: `${customPrefix}${ROUTE_USER_DEVICE_REMOVE}`, + }), + ).toBe(true); + await fastify.close(); + }); +}); + +describe("firebasePlugin — notification route registration", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not register notification route when notification.test.enabled is not set", async () => { + fastify = buildFastify({ enabled: false }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ + method: "POST", + url: `${fastify.config.firebase.routePrefix}${ROUTE_SEND_NOTIFICATION}`, + }), + ).toBe(false); + await fastify.close(); + }); + + it("registers notification route when notification.test.enabled === true", async () => { + fastify = buildFastify({ + enabled: false, + notification: { test: { enabled: true, path: ROUTE_SEND_NOTIFICATION } }, + }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ + method: "POST", + url: `${fastify.config.firebase.routePrefix}${ROUTE_SEND_NOTIFICATION}`, + }), + ).toBe(true); + await fastify.close(); + }); + + it("registers notification test route at default path when test is enabled but path is omitted", async () => { + fastify = buildFastify({ + enabled: false, + notification: { test: { enabled: true } }, + }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ + method: "POST", + url: `${fastify.config.firebase.routePrefix}${ROUTE_SEND_NOTIFICATION}`, + }), + ).toBe(true); + await fastify.close(); + }); + + it("uses custom notification test path when configured", async () => { + const customPath = "/custom-notify"; + fastify = buildFastify({ + enabled: false, + notification: { test: { enabled: true, path: customPath } }, + }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ + method: "POST", + url: `${fastify.config.firebase.routePrefix}${customPath}`, + }), + ).toBe(true); + await fastify.close(); + }); + + it("skips notification routes when routes.notifications.disabled === true", async () => { + fastify = buildFastify({ + enabled: false, + notification: { test: { enabled: true, path: ROUTE_SEND_NOTIFICATION } }, + routes: { notifications: { disabled: true } }, + }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ + method: "POST", + url: `${fastify.config.firebase.routePrefix}${ROUTE_SEND_NOTIFICATION}`, + }), + ).toBe(false); + await fastify.close(); + }); +}); diff --git a/packages/firebase/src/__test__/queries.test.ts b/packages/firebase/src/__test__/queries.test.ts new file mode 100644 index 000000000..273762a11 --- /dev/null +++ b/packages/firebase/src/__test__/queries.test.ts @@ -0,0 +1,41 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +/* istanbul ignore file */ +import { describe, expect, it } from "vitest"; + +import { TABLE_USER_DEVICES } from "../constants"; +import { createUserDevicesTableQuery } from "../migrations/queries"; + +const makeConfig = (tableName?: string): ApiConfig => + ({ + firebase: { + table: tableName ? { userDevices: { name: tableName } } : undefined, + }, + }) as unknown as ApiConfig; + +describe("createUserDevicesTableQuery", () => { + it("uses TABLE_USER_DEVICES constant as default table name when not configured", () => { + const query = createUserDevicesTableQuery(makeConfig()); + + expect(query.sql).toContain(TABLE_USER_DEVICES); + }); + + it("uses custom table name from config.firebase.table.userDevices.name", () => { + const query = createUserDevicesTableQuery(makeConfig("custom_devices")); + + expect(query.sql).toContain("custom_devices"); + expect(query.sql).not.toContain(TABLE_USER_DEVICES); + }); + + it("generates a CREATE TABLE IF NOT EXISTS statement", () => { + const query = createUserDevicesTableQuery(makeConfig()); + + expect(query.sql).toMatch(/CREATE TABLE IF NOT EXISTS/i); + }); + + it("creates index on user_id and device_token", () => { + const query = createUserDevicesTableQuery(makeConfig()); + + expect(query.sql).toMatch(/CREATE INDEX IF NOT EXISTS/i); + }); +}); diff --git a/packages/firebase/src/__test__/service.test.ts b/packages/firebase/src/__test__/service.test.ts new file mode 100644 index 000000000..686de39ca --- /dev/null +++ b/packages/firebase/src/__test__/service.test.ts @@ -0,0 +1,150 @@ +/* istanbul ignore file */ +import { newDb } from "pg-mem"; +import { describe, expect, it } from "vitest"; + +import UserDeviceService from "../model/userDevice/service"; +import createConfig from "./helpers/createConfig"; +import createDatabase from "./helpers/createDatabase"; + +const CREATE_TABLE_SQL = ` + CREATE TABLE user_devices ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + device_token VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); +`; + +describe("UserDeviceService — getByUserId", async () => { + const db = newDb(); + db.public.none(CREATE_TABLE_SQL); + db.public.none(` + INSERT INTO user_devices (user_id, device_token) VALUES + ('user-1', 'token-a'), + ('user-1', 'token-b'), + ('user-2', 'token-c'); + `); + + const config = createConfig(); + const database = await createDatabase({ db }); + const service = new UserDeviceService(config, database); + + it("returns all devices for a given userId", async () => { + const result = await service.getByUserId("user-1"); + + expect(result).toHaveLength(2); + expect(result?.every((d) => d.userId === "user-1")).toBe(true); + }); + + it("returns device tokens for the user", async () => { + const result = await service.getByUserId("user-1"); + const tokens = result?.map((d) => d.deviceToken).toSorted(); + + expect(tokens).toEqual(["token-a", "token-b"]); + }); + + it("returns an empty array when user has no devices", async () => { + const result = await service.getByUserId("user-unknown"); + + expect(result).toEqual([]); + }); + + it("does not return devices belonging to other users", async () => { + const result = await service.getByUserId("user-2"); + + expect(result).toHaveLength(1); + expect(result?.[0].deviceToken).toBe("token-c"); + }); +}); + +describe("UserDeviceService — removeByDeviceToken", async () => { + const db = newDb(); + db.public.none(CREATE_TABLE_SQL); + db.public.none(` + INSERT INTO user_devices (user_id, device_token) VALUES + ('user-1', 'token-to-delete'), + ('user-1', 'token-keep'); + `); + + const config = createConfig(); + const database = await createDatabase({ db }); + const service = new UserDeviceService(config, database); + + it("returns the deleted device record", async () => { + const result = await service.removeByDeviceToken("token-to-delete"); + + expect(result).toBeDefined(); + expect(result?.deviceToken).toBe("token-to-delete"); + expect(result?.userId).toBe("user-1"); + }); + + it("removes the device from the database", async () => { + const remaining = await service.getByUserId("user-1"); + + // token-to-delete was removed in the previous test, only token-keep remains + expect(remaining).toHaveLength(1); + expect(remaining?.[0].deviceToken).toBe("token-keep"); + }); + + it("returns null when the token does not exist", async () => { + const result = await service.removeByDeviceToken("nonexistent-token"); + + // slonik's maybeOne returns null (not undefined) when no row is found + expect(result).toBeNull(); + }); +}); + +describe("UserDeviceService — preCreate (deduplication)", async () => { + const db = newDb(); + db.public.none(CREATE_TABLE_SQL); + db.public.none(` + INSERT INTO user_devices (user_id, device_token) VALUES ('user-1', 'existing-token'); + `); + + const config = createConfig(); + const database = await createDatabase({ db }); + const service = new UserDeviceService(config, database); + + it("removes the existing row with the same deviceToken before creating", async () => { + await service.create({ deviceToken: "existing-token", userId: "user-2" }); + + const user1Devices = await service.getByUserId("user-1"); + expect(user1Devices).toHaveLength(0); + }); + + it("creates a new row with the new userId after deduplication", async () => { + const user2Devices = await service.getByUserId("user-2"); + + expect(user2Devices).toHaveLength(1); + expect(user2Devices?.[0].deviceToken).toBe("existing-token"); + expect(user2Devices?.[0].userId).toBe("user-2"); + }); +}); + +describe("UserDeviceService — custom table name", async () => { + const db = newDb(); + db.public.none(` + CREATE TABLE custom_devices ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + device_token VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO custom_devices (user_id, device_token) VALUES ('user-1', 'token-a'); + `); + + const config = createConfig({ + table: { userDevices: { name: "custom_devices" } }, + }); + const database = await createDatabase({ db }); + const service = new UserDeviceService(config, database); + + it("queries the custom table name configured in config.firebase.table.userDevices.name", async () => { + const result = await service.getByUserId("user-1"); + + expect(result).toHaveLength(1); + expect(result?.[0].deviceToken).toBe("token-a"); + }); +}); diff --git a/packages/firebase/src/__test__/sqlFactory.test.ts b/packages/firebase/src/__test__/sqlFactory.test.ts new file mode 100644 index 000000000..3f6aa6be7 --- /dev/null +++ b/packages/firebase/src/__test__/sqlFactory.test.ts @@ -0,0 +1,70 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +/* istanbul ignore file */ +import { describe, expect, it, vi } from "vitest"; + +import { TABLE_USER_DEVICES } from "../constants"; +import UserDeviceSqlFactory from "../model/userDevice/sqlFactory"; + +const makeConfig = (tableName?: string): ApiConfig => + ({ + firebase: { + table: tableName ? { userDevices: { name: tableName } } : undefined, + }, + }) as unknown as ApiConfig; + +// We only need a minimal database stub — SQL tokens are built without executing queries +const mockDatabase = { + connect: vi.fn(), + pool: {}, + query: vi.fn(), +} as unknown as Parameters[1]; + +describe("UserDeviceSqlFactory — table getter", () => { + it("returns TABLE_USER_DEVICES when not configured in config", () => { + const factory = new UserDeviceSqlFactory(makeConfig(), mockDatabase); + + expect(factory.table).toBe(TABLE_USER_DEVICES); + }); + + it("returns the custom table name from config.firebase.table.userDevices.name", () => { + const factory = new UserDeviceSqlFactory( + makeConfig("my_devices"), + mockDatabase, + ); + + expect(factory.table).toBe("my_devices"); + }); +}); + +describe("UserDeviceSqlFactory — getDeleteExistingTokenSql", () => { + it("generates a DELETE SQL statement", () => { + const factory = new UserDeviceSqlFactory(makeConfig(), mockDatabase); + const query = factory.getDeleteExistingTokenSql("token-abc"); + + expect(query.sql).toMatch(/DELETE/i); + }); + + it("includes RETURNING * in the DELETE statement", () => { + const factory = new UserDeviceSqlFactory(makeConfig(), mockDatabase); + const query = factory.getDeleteExistingTokenSql("token-abc"); + + expect(query.sql).toMatch(/RETURNING \*/i); + }); +}); + +describe("UserDeviceSqlFactory — getFindByUserIdSql", () => { + it("generates a SELECT SQL statement", () => { + const factory = new UserDeviceSqlFactory(makeConfig(), mockDatabase); + const query = factory.getFindByUserIdSql("user-123"); + + expect(query.sql).toMatch(/SELECT/i); + }); + + it("filters by user_id", () => { + const factory = new UserDeviceSqlFactory(makeConfig(), mockDatabase); + const query = factory.getFindByUserIdSql("user-123"); + + expect(query.sql).toMatch(/user_id/i); + }); +}); diff --git a/packages/firebase/src/__test__/userDeviceResolver.test.ts b/packages/firebase/src/__test__/userDeviceResolver.test.ts new file mode 100644 index 000000000..3114a435c --- /dev/null +++ b/packages/firebase/src/__test__/userDeviceResolver.test.ts @@ -0,0 +1,160 @@ +import type { MercuriusContext } from "mercurius"; + +/* istanbul ignore file */ +import { mercurius } from "mercurius"; +import { describe, expect, it, vi } from "vitest"; + +import userDeviceResolver from "../model/userDevice/graphql/resolver"; + +const mockCreate = vi + .fn() + .mockResolvedValue({ deviceToken: "token-abc", id: 1, userId: "user-1" }); +const mockGetByUserId = vi + .fn() + .mockResolvedValue([{ deviceToken: "token-abc", id: 1, userId: "user-1" }]); +const mockRemoveByDeviceToken = vi + .fn() + .mockResolvedValue({ deviceToken: "token-abc", id: 1, userId: "user-1" }); + +vi.mock("../model/userDevice/service", () => ({ + default: vi.fn().mockImplementation(() => ({ + create: mockCreate, + getByUserId: mockGetByUserId, + removeByDeviceToken: mockRemoveByDeviceToken, + })), +})); + +const makeContext = ( + overrides: Partial = {}, +): MercuriusContext => + ({ + app: { log: { error: vi.fn() } }, + config: { firebase: { enabled: true } }, + database: {}, + dbSchema: "", + user: { id: "user-1" }, + ...overrides, + }) as unknown as MercuriusContext; + +describe("userDeviceResolver.addUserDevice", () => { + it("returns 404 ErrorWithProps when firebase is disabled", async () => { + const context = makeContext({ + config: { + firebase: { enabled: false }, + } as unknown as MercuriusContext["config"], + }); + const result = await userDeviceResolver.Mutation.addUserDevice( + undefined, + { data: { deviceToken: "token-abc" } }, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(404); + }); + + it("returns 401 ErrorWithProps when user is not in context", async () => { + const context = makeContext({ user: undefined }); + const result = await userDeviceResolver.Mutation.addUserDevice( + undefined, + { data: { deviceToken: "token-abc" } }, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(401); + }); + + it("calls service.create with userId and deviceToken and returns result", async () => { + const context = makeContext(); + const result = await userDeviceResolver.Mutation.addUserDevice( + undefined, + { data: { deviceToken: "token-abc" } }, + context, + ); + + expect(mockCreate).toHaveBeenCalledWith({ + deviceToken: "token-abc", + userId: "user-1", + }); + expect(result).toEqual({ + deviceToken: "token-abc", + id: 1, + userId: "user-1", + }); + }); +}); + +describe("userDeviceResolver.removeUserDevice", () => { + it("returns 404 ErrorWithProps when firebase is disabled", async () => { + const context = makeContext({ + config: { + firebase: { enabled: false }, + } as unknown as MercuriusContext["config"], + }); + const result = await userDeviceResolver.Mutation.removeUserDevice( + undefined, + { data: { deviceToken: "token-abc" } }, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(404); + }); + + it("returns 401 ErrorWithProps when user is not in context", async () => { + const context = makeContext({ user: undefined }); + const result = await userDeviceResolver.Mutation.removeUserDevice( + undefined, + { data: { deviceToken: "token-abc" } }, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(401); + }); + + it("returns 403 ErrorWithProps when user has no registered devices", async () => { + mockGetByUserId.mockResolvedValueOnce([]); + const context = makeContext(); + const result = await userDeviceResolver.Mutation.removeUserDevice( + undefined, + { data: { deviceToken: "token-abc" } }, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(403); + }); + + it("returns 403 ErrorWithProps when device is not owned by requesting user", async () => { + mockGetByUserId.mockResolvedValueOnce([ + { deviceToken: "different-token", id: 2, userId: "user-1" }, + ]); + const context = makeContext(); + const result = await userDeviceResolver.Mutation.removeUserDevice( + undefined, + { data: { deviceToken: "token-abc" } }, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(403); + }); + + it("calls service.removeByDeviceToken and returns result when device is owned by user", async () => { + const context = makeContext(); + const result = await userDeviceResolver.Mutation.removeUserDevice( + undefined, + { data: { deviceToken: "token-abc" } }, + context, + ); + + expect(mockRemoveByDeviceToken).toHaveBeenCalledWith("token-abc"); + expect(result).toEqual({ + deviceToken: "token-abc", + id: 1, + userId: "user-1", + }); + }); +}); diff --git a/packages/firebase/src/index.ts b/packages/firebase/src/index.ts index 65bb99685..9939b7e86 100644 --- a/packages/firebase/src/index.ts +++ b/packages/firebase/src/index.ts @@ -1,10 +1,10 @@ import { verifySession } from "supertokens-node/recipe/session/framework/fastify"; +import type { User } from "./types"; + import notificationHandlers from "./model/notification/handlers"; import deviceHandlers from "./model/userDevice/handlers"; -import type { User } from "./types"; - declare module "fastify" { interface FastifyInstance { verifySession: typeof verifySession; @@ -24,12 +24,28 @@ declare module "mercurius" { declare module "@prefabs.tech/fastify-config" { interface ApiConfig { firebase: { - enabled?: boolean; credentials?: { - projectId: string; - privateKey: string; clientEmail: string; + privateKey: string; + projectId: string; + }; + enabled?: boolean; + handlers?: { + notification?: { + sendNotification?: typeof notificationHandlers.sendNotification; + }; + userDevice?: { + addUserDevice?: typeof deviceHandlers.addUserDevice; + removeUserDevice?: typeof deviceHandlers.removeUserDevice; + }; + }; + notification?: { + test?: { + enabled: boolean; + path: string; + }; }; + routePrefix?: string; routes?: { notifications?: { disabled: boolean; @@ -38,41 +54,25 @@ declare module "@prefabs.tech/fastify-config" { disabled: boolean; }; }; - routePrefix?: string; table?: { userDevices?: { name: string; }; }; - notification?: { - test?: { - enabled: boolean; - path: string; - }; - }; - handlers?: { - userDevice?: { - addUserDevice?: typeof deviceHandlers.addUserDevice; - removeUserDevice?: typeof deviceHandlers.removeUserDevice; - }; - notification?: { - sendNotification?: typeof notificationHandlers.sendNotification; - }; - }; }; } } -export { default } from "./plugin"; +export * from "./constants"; +export { default as firebaseSchema } from "./graphql/schema"; +export * from "./lib"; + +export * from "./migrations/queries"; export { default as notificationRoutes } from "./model/notification/controller"; export { default as notificationResolver } from "./model/notification/graphql/resolver"; +export { default as userDeviceRoutes } from "./model/userDevice/controller"; export { default as userDeviceResolver } from "./model/userDevice/graphql/resolver"; -export { default as userDeviceRoutes } from "./model/userDevice/controller"; export { default as UserDeviceService } from "./model/userDevice/service"; -export { default as firebaseSchema } from "./graphql/schema"; - -export * from "./constants"; -export * from "./lib"; -export * from "./migrations/queries"; +export { default } from "./plugin"; diff --git a/packages/firebase/src/lib/initializeFirebase.ts b/packages/firebase/src/lib/initializeFirebase.ts index 9844233fe..da9d8654e 100644 --- a/packages/firebase/src/lib/initializeFirebase.ts +++ b/packages/firebase/src/lib/initializeFirebase.ts @@ -1,8 +1,8 @@ -import admin from "firebase-admin"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { FastifyInstance } from "fastify"; +import admin from "firebase-admin"; + const initializeFirebase = (config: ApiConfig, fastify: FastifyInstance) => { if (admin.apps.length > 0) { return; @@ -16,12 +16,12 @@ const initializeFirebase = (config: ApiConfig, fastify: FastifyInstance) => { try { admin.initializeApp({ credential: admin.credential.cert({ - projectId: config.firebase.credentials?.projectId, + clientEmail: config.firebase.credentials?.clientEmail, privateKey: config.firebase.credentials?.privateKey.replaceAll( String.raw`\n`, "\n", ), - clientEmail: config.firebase.credentials?.clientEmail, + projectId: config.firebase.credentials?.projectId, }), }); } catch (error) { diff --git a/packages/firebase/src/lib/sendPushNotification.ts b/packages/firebase/src/lib/sendPushNotification.ts index 2b8a7430b..69f5234dc 100644 --- a/packages/firebase/src/lib/sendPushNotification.ts +++ b/packages/firebase/src/lib/sendPushNotification.ts @@ -1,7 +1,7 @@ -import admin from "firebase-admin"; - import type { MulticastMessage } from "firebase-admin/lib/messaging/messaging-api"; +import admin from "firebase-admin"; + const sendPushNotification = async (message: MulticastMessage) => { await admin.messaging().sendEachForMulticast(message); }; diff --git a/packages/firebase/src/migrations/queries.ts b/packages/firebase/src/migrations/queries.ts index 20d7a7d78..735711634 100644 --- a/packages/firebase/src/migrations/queries.ts +++ b/packages/firebase/src/migrations/queries.ts @@ -1,11 +1,11 @@ -import { sql } from "slonik"; - -import { TABLE_USER_DEVICES } from "../constants"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { QuerySqlToken } from "slonik"; import type { ZodTypeAny } from "zod"; +import { sql } from "slonik"; + +import { TABLE_USER_DEVICES } from "../constants"; + const createUserDevicesTableQuery = ( config: ApiConfig, ): QuerySqlToken => { diff --git a/packages/firebase/src/migrations/runMigrations.ts b/packages/firebase/src/migrations/runMigrations.ts index e89c1ffae..ac681adec 100644 --- a/packages/firebase/src/migrations/runMigrations.ts +++ b/packages/firebase/src/migrations/runMigrations.ts @@ -1,8 +1,8 @@ -import { createUserDevicesTableQuery } from "./queries"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Database } from "@prefabs.tech/fastify-slonik"; +import { createUserDevicesTableQuery } from "./queries"; + const runMigrations = async (database: Database, config: ApiConfig) => { await database.connect(async (connection) => { await connection.query(createUserDevicesTableQuery(config)); diff --git a/packages/firebase/src/model/notification/controller.ts b/packages/firebase/src/model/notification/controller.ts index ae6f28863..c00c89453 100644 --- a/packages/firebase/src/model/notification/controller.ts +++ b/packages/firebase/src/model/notification/controller.ts @@ -1,12 +1,16 @@ -import handlers from "./handlers"; -import { sendNotificationSchema } from "./schema"; +import type { FastifyInstance } from "fastify"; + import { ROUTE_SEND_NOTIFICATION } from "../../constants"; import isFirebaseEnabled from "../../middlewares/isFirebaseEnabled"; +import handlers from "./handlers"; +import { sendNotificationSchema } from "./schema"; -import type { FastifyInstance } from "fastify"; - +/** + * Registers an authenticated test endpoint to send a push notification to a user’s + * registered device tokens (enabled via `config.firebase.notification.test`). + */ const plugin = async (fastify: FastifyInstance) => { - const handlersConfig = fastify.config.firebase.handlers?.userDevice; + const handlersConfig = fastify.config.firebase.handlers?.notification; const notificationConfig = fastify.config.firebase.notification; if (notificationConfig?.test?.enabled) { @@ -16,7 +20,7 @@ const plugin = async (fastify: FastifyInstance) => { preHandler: [fastify.verifySession(), isFirebaseEnabled(fastify)], schema: sendNotificationSchema, }, - handlersConfig?.addUserDevice || handlers.sendNotification, + handlersConfig?.sendNotification || handlers.sendNotification, ); } }; diff --git a/packages/firebase/src/model/notification/graphql/resolver.ts b/packages/firebase/src/model/notification/graphql/resolver.ts index e493625a2..a54955eae 100644 --- a/packages/firebase/src/model/notification/graphql/resolver.ts +++ b/packages/firebase/src/model/notification/graphql/resolver.ts @@ -1,27 +1,27 @@ +import type { MulticastMessage } from "firebase-admin/lib/messaging/messaging-api"; +import type { MercuriusContext } from "mercurius"; + import { mercurius } from "mercurius"; import { sendPushNotification } from "../../../lib"; import UserDeviceService from "../../userDevice/service"; -import type { MulticastMessage } from "firebase-admin/lib/messaging/messaging-api"; -import type { MercuriusContext } from "mercurius"; - const Mutation = { sendNotification: async ( parent: unknown, arguments_: { data: { - userId: string; - title: string; body: string; data: { [key: string]: string; }; + title: string; + userId: string; }; }, context: MercuriusContext, ) => { - const { app, config, dbSchema, database, user } = context; + const { app, config, database, dbSchema, user } = context; if (!user) { return new mercurius.ErrorWithProps("unauthorized", {}, 401); @@ -32,7 +32,7 @@ const Mutation = { } try { - const { userId: receiverId, title, body, data } = arguments_.data; + const { body, data, title, userId: receiverId } = arguments_.data; if (!receiverId) { return new mercurius.ErrorWithProps("Receiver id is required", {}, 400); @@ -59,12 +59,12 @@ const Mutation = { ); const message: MulticastMessage = { - tokens, + data, notification: { - title, body, + title, }, - data, + tokens, }; await sendPushNotification(message); diff --git a/packages/firebase/src/model/notification/handlers/sendNotification.ts b/packages/firebase/src/model/notification/handlers/sendNotification.ts index 2c6ee657a..4a43a4ce8 100644 --- a/packages/firebase/src/model/notification/handlers/sendNotification.ts +++ b/packages/firebase/src/model/notification/handlers/sendNotification.ts @@ -1,11 +1,12 @@ -import { sendPushNotification } from "../../../lib"; -import DeviceService from "../../userDevice/service"; - -import type { TestNotificationInput } from "../../../types"; import type { FastifyReply } from "fastify"; import type { MulticastMessage } from "firebase-admin/lib/messaging/messaging-api"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { TestNotificationInput } from "../../../types"; + +import { sendPushNotification } from "../../../lib"; +import DeviceService from "../../userDevice/service"; + const testPushNotification = async ( request: SessionRequest, reply: FastifyReply, @@ -18,8 +19,8 @@ const testPushNotification = async ( const { body, - title, data, + title, userId: receiverId, } = request.body as TestNotificationInput; @@ -41,10 +42,10 @@ const testPushNotification = async ( const message: MulticastMessage = { android: { - priority: "high", notification: { sound: "default", }, + priority: "high", }, apns: { payload: { @@ -53,16 +54,16 @@ const testPushNotification = async ( }, }, }, - tokens, - notification: { - title, - body, - }, data: { ...data, + body, title, + }, + notification: { body, + title, }, + tokens, }; await sendPushNotification(message); diff --git a/packages/firebase/src/model/notification/schema.ts b/packages/firebase/src/model/notification/schema.ts index cd80537d9..e94b41348 100644 --- a/packages/firebase/src/model/notification/schema.ts +++ b/packages/firebase/src/model/notification/schema.ts @@ -1,26 +1,26 @@ export const sendNotificationSchema = { - description: "Send a notification to a specific user", - operationId: "sendNotification", body: { - type: "object", properties: { - title: { type: "string" }, message: { type: "string" }, + title: { type: "string" }, userId: { type: "string" }, }, required: ["title", "message", "userId"], + type: "object", }, + description: "Send a notification to a specific user", + operationId: "sendNotification", response: { 200: { - type: "object", properties: { - success: { type: "boolean" }, message: { type: "string" }, + success: { type: "boolean" }, }, + type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", diff --git a/packages/firebase/src/model/userDevice/controller.ts b/packages/firebase/src/model/userDevice/controller.ts index 4587a71ee..f9285679f 100644 --- a/packages/firebase/src/model/userDevice/controller.ts +++ b/packages/firebase/src/model/userDevice/controller.ts @@ -1,12 +1,12 @@ -import handlers from "./handlers"; -import { deleteUserDeviceSchema, postUserDeviceSchema } from "./schema"; +import type { FastifyInstance } from "fastify"; + import { ROUTE_USER_DEVICE_ADD, ROUTE_USER_DEVICE_REMOVE, } from "../../constants"; import isFirebaseEnabled from "../../middlewares/isFirebaseEnabled"; - -import type { FastifyInstance } from "fastify"; +import handlers from "./handlers"; +import { deleteUserDeviceSchema, postUserDeviceSchema } from "./schema"; const plugin = async (fastify: FastifyInstance) => { const handlersConfig = fastify.config.firebase.handlers?.userDevice; diff --git a/packages/firebase/src/model/userDevice/graphql/resolver.ts b/packages/firebase/src/model/userDevice/graphql/resolver.ts index ca39c005c..1f350b59b 100644 --- a/packages/firebase/src/model/userDevice/graphql/resolver.ts +++ b/packages/firebase/src/model/userDevice/graphql/resolver.ts @@ -12,7 +12,7 @@ const Mutation = { }, context: MercuriusContext, ) => { - const { app, config, dbSchema, database, user } = context; + const { app, config, database, dbSchema, user } = context; if (config.firebase.enabled === false) { return new mercurius.ErrorWithProps("Firebase is not enabled", {}, 404); @@ -27,7 +27,7 @@ const Mutation = { const service = new Service(config, database, dbSchema); - return await service.create({ userId: user.id, deviceToken }); + return await service.create({ deviceToken, userId: user.id }); } catch (error) { app.log.error(error); @@ -47,7 +47,7 @@ const Mutation = { }, context: MercuriusContext, ) => { - const { app, config, dbSchema, database, user } = context; + const { app, config, database, dbSchema, user } = context; if (config.firebase.enabled === false) { return new mercurius.ErrorWithProps("Firebase is not enabled", {}, 404); diff --git a/packages/firebase/src/model/userDevice/handlers/addUserDevice.ts b/packages/firebase/src/model/userDevice/handlers/addUserDevice.ts index 6f09182ac..8f29555f2 100644 --- a/packages/firebase/src/model/userDevice/handlers/addUserDevice.ts +++ b/packages/firebase/src/model/userDevice/handlers/addUserDevice.ts @@ -1,9 +1,10 @@ -import Service from "../service"; - -import type { UserDeviceCreateInput } from "../../../types"; import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { UserDeviceCreateInput } from "../../../types"; + +import Service from "../service"; + const addUserDevice = async (request: SessionRequest, reply: FastifyReply) => { const { body, config, dbSchema, slonik, user } = request; @@ -15,7 +16,7 @@ const addUserDevice = async (request: SessionRequest, reply: FastifyReply) => { const service = new Service(config, slonik, dbSchema); - reply.send(await service.create({ userId: user.id, deviceToken })); + reply.send(await service.create({ deviceToken, userId: user.id })); }; export default addUserDevice; diff --git a/packages/firebase/src/model/userDevice/handlers/removeUserDevice.ts b/packages/firebase/src/model/userDevice/handlers/removeUserDevice.ts index 2b193124d..f5f8504fb 100644 --- a/packages/firebase/src/model/userDevice/handlers/removeUserDevice.ts +++ b/packages/firebase/src/model/userDevice/handlers/removeUserDevice.ts @@ -1,8 +1,8 @@ -import Service from "../service"; - import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import Service from "../service"; + const removeUserDevice = async ( request: SessionRequest, reply: FastifyReply, diff --git a/packages/firebase/src/model/userDevice/schema.ts b/packages/firebase/src/model/userDevice/schema.ts index 76df8f93a..6192dd943 100644 --- a/packages/firebase/src/model/userDevice/schema.ts +++ b/packages/firebase/src/model/userDevice/schema.ts @@ -1,32 +1,32 @@ const userDeviceSchema = { - type: "object", properties: { - userId: { type: "string" }, - deviceToken: { type: "string" }, createdAt: { type: "number" }, + deviceToken: { type: "string" }, updatedAt: { type: "number" }, + userId: { type: "string" }, }, required: ["userId", "deviceToken", "createdAt", "updatedAt"], + type: "object", }; export const deleteUserDeviceSchema = { - description: "Delete a user device by device token", - operationId: "deleteUserDevice", body: { - type: "object", properties: { deviceToken: { type: "string" }, }, required: ["deviceToken"], + type: "object", }, + description: "Delete a user device by device token", + operationId: "deleteUserDevice", response: { 200: { ...userDeviceSchema, nullable: true, }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", @@ -36,23 +36,23 @@ export const deleteUserDeviceSchema = { }; export const postUserDeviceSchema = { - description: "Register a new user device", - operationId: "postUserDevice", body: { - type: "object", properties: { deviceToken: { type: "string" }, }, required: ["deviceToken"], + type: "object", }, + description: "Register a new user device", + operationId: "postUserDevice", response: { 200: { ...userDeviceSchema, nullable: true, }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", diff --git a/packages/firebase/src/model/userDevice/service.ts b/packages/firebase/src/model/userDevice/service.ts index ae93d4bea..8e079d36c 100644 --- a/packages/firebase/src/model/userDevice/service.ts +++ b/packages/firebase/src/model/userDevice/service.ts @@ -1,18 +1,26 @@ import { BaseService } from "@prefabs.tech/fastify-slonik"; -import UserDeviceSqlFactory from "./sqlFactory"; import { UserDevice, UserDeviceCreateInput, UserDeviceUpdateInput, } from "../../types"; +import UserDeviceSqlFactory from "./sqlFactory"; class UserDeviceService extends BaseService< UserDevice, UserDeviceCreateInput, UserDeviceUpdateInput > { - async getByUserId(userId: string): Promise { + get factory(): UserDeviceSqlFactory { + return super.factory as UserDeviceSqlFactory; + } + + get sqlFactoryClass() { + return UserDeviceSqlFactory; + } + + async getByUserId(userId: string): Promise { const query = this.factory.getFindByUserIdSql(userId); const result = await this.database.connect((connection) => { @@ -24,7 +32,7 @@ class UserDeviceService extends BaseService< async removeByDeviceToken( deviceToken: string, - ): Promise { + ): Promise { const query = this.factory.getDeleteExistingTokenSql(deviceToken); const result = await this.database.connect((connection) => { @@ -34,14 +42,6 @@ class UserDeviceService extends BaseService< return result; } - get factory(): UserDeviceSqlFactory { - return super.factory as UserDeviceSqlFactory; - } - - get sqlFactoryClass() { - return UserDeviceSqlFactory; - } - protected async preCreate( data: UserDeviceCreateInput, ): Promise { diff --git a/packages/firebase/src/model/userDevice/sqlFactory.ts b/packages/firebase/src/model/userDevice/sqlFactory.ts index 6fe663bd4..25f10ecca 100644 --- a/packages/firebase/src/model/userDevice/sqlFactory.ts +++ b/packages/firebase/src/model/userDevice/sqlFactory.ts @@ -6,6 +6,10 @@ import { TABLE_USER_DEVICES } from "../../constants"; class UserDeviceSqlFactory extends DefaultSqlFactory { static readonly TABLE = TABLE_USER_DEVICES; + get table() { + return this.config.firebase.table?.userDevices?.name || super.table; + } + getDeleteExistingTokenSql(token: string): QuerySqlToken { return sql.type(this.validationSchema)` DELETE @@ -22,10 +26,6 @@ class UserDeviceSqlFactory extends DefaultSqlFactory { ${this.getWhereFragment({ filterFragment: sql.fragment`user_id = ${userId}` })}; `; } - - get table() { - return this.config.firebase.table?.userDevices?.name || super.table; - } } export default UserDeviceSqlFactory; diff --git a/packages/firebase/src/plugin.ts b/packages/firebase/src/plugin.ts index 527cffbce..e6e9e6d77 100644 --- a/packages/firebase/src/plugin.ts +++ b/packages/firebase/src/plugin.ts @@ -1,3 +1,5 @@ +import type { FastifyInstance } from "fastify"; + import FastifyPlugin from "fastify-plugin"; import { initializeFirebase } from "./lib"; @@ -5,10 +7,8 @@ import runMigrations from "./migrations/runMigrations"; import notificationRoutes from "./model/notification/controller"; import userDevicesRoutes from "./model/userDevice/controller"; -import type { FastifyInstance } from "fastify"; - const plugin = async (fastify: FastifyInstance) => { - const { config, slonik, log } = fastify; + const { config, log, slonik } = fastify; if (config.firebase.enabled === false) { log.info("fastify-firebase plugin is not enabled"); diff --git a/packages/firebase/src/types.ts b/packages/firebase/src/types.ts index 9c4910c51..65b7d49c9 100644 --- a/packages/firebase/src/types.ts +++ b/packages/firebase/src/types.ts @@ -1,23 +1,23 @@ import "@prefabs.tech/fastify-error-handler"; -interface UserDevice { +interface TestNotificationInput { + body: string; + data?: { + [key: string]: string; + }; + title: string; userId: string; - deviceToken: string; - createdAt: number; - updatedAt: number; } interface User { id: string; } -interface TestNotificationInput { +interface UserDevice { + createdAt: number; + deviceToken: string; + updatedAt: number; userId: string; - title: string; - body: string; - data?: { - [key: string]: string; - }; } type UserDeviceCreateInput = Partial< @@ -25,7 +25,7 @@ type UserDeviceCreateInput = Partial< >; type UserDeviceUpdateInput = Partial< - Omit + Omit >; export type { diff --git a/packages/firebase/tsconfig.json b/packages/firebase/tsconfig.json index 8a8ad62d0..1628077b9 100644 --- a/packages/firebase/tsconfig.json +++ b/packages/firebase/tsconfig.json @@ -1,13 +1,9 @@ { "extends": "@prefabs.tech/tsconfig/fastify.json", - "exclude": [ - "src/**/__test__/**/*", - ], + "exclude": ["src/**/__test__/**/*"], "compilerOptions": { "baseUrl": "./", - "outDir": "./dist", + "outDir": "./dist" }, - "include": [ - "src/**/*.ts" - ] + "include": ["src/**/*.ts"] } diff --git a/packages/firebase/vite.config.ts b/packages/firebase/vite.config.ts index 61b6f897a..76412931e 100644 --- a/packages/firebase/vite.config.ts +++ b/packages/firebase/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { dependencies, peerDependencies } from "./package.json"; @@ -27,8 +26,8 @@ export default defineConfig(({ mode }) => { globals: { "@prefabs.tech/fastify-error-handler": "PrefabsTechFastifyErrorHandler", - "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", "@prefabs.tech/fastify-graphql": "PrefabsTechFastifyGraphql", + "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", fastify: "Fastify", "fastify-plugin": "FastifyPlugin", "firebase-admin": "FirebaseAdmin", diff --git a/packages/graphql/FEATURES.md b/packages/graphql/FEATURES.md new file mode 100644 index 000000000..b7e42b94b --- /dev/null +++ b/packages/graphql/FEATURES.md @@ -0,0 +1,51 @@ + + +# @prefabs.tech/fastify-graphql — Features + +## Plugin Registration + +1. **Registration lifecycle logging** — the plugin logs `"Registering fastify-graphql plugin"` when registration starts. It logs a warning when using config fallback and logs `"GraphQL API not enabled"` when `enabled` is falsy. + +2. **Conditional mercurius registration** — when `enabled` is falsy, mercurius is not registered and `"GraphQL API not enabled"` is logged; the Fastify instance continues without a `/graphql` endpoint. + +3. **Config fallback** — when options object is empty (no options passed directly to `register()`), the plugin reads `fastify.config.graphql` and uses that as the options. Logs a warning when falling back. Throws `"Missing graphql configuration"` if `fastify.config.graphql` is also undefined. + +## Context Building + +4. **Default context factory** — when mercurius is registered, the plugin sets `context: buildContext(options.plugins)` first, then spreads `...options`. If a caller passes `options.context`, that caller-provided context overrides the default factory. + +5. **Automatic context building (default path)** — when the default context factory is used, each GraphQL request gets `config` (from `request.config`), `database` (from `request.slonik`), and `dbSchema` (from `request.dbSchema`) injected into Mercurius context. + +6. **Plugin-based context extension** — with the default context factory, plugins listed in `options.plugins` have `updateContext(context, request, reply)` called per-request, in array order, so each plugin can extend the shared Mercurius context. + +## Types & Module Augmentation + +7. **`GraphqlConfig` interface** — extends `MercuriusOptions` with two additional fields: `enabled?: boolean` and `plugins?: GraphqlEnabledPlugin[]`. + +8. **`GraphqlOptions` type alias** — exported alias for `GraphqlConfig`. + +9. **`GraphqlEnabledPlugin` interface** — a type that extends both `FastifyPluginAsync` and `FastifyPluginCallback`, plus carries an `updateContext(context: MercuriusContext, request: FastifyRequest, reply: FastifyReply): Promise` method required by the context extension system. + +10. **`MercuriusContext` augmentation** — adds typed `config: ApiConfig`, `database: Database`, and `dbSchema: string` to the global `mercurius` module's `MercuriusContext` interface. + +11. **`ApiConfig` augmentation** — adds `graphql: GraphqlConfig` to `@prefabs.tech/fastify-config`'s `ApiConfig` interface, making `fastify.config.graphql` fully typed. + +## Built-in Schema + +12. **`baseSchema` export** — a `DocumentNode` (parsed with `gql`) containing ready-to-merge GraphQL definitions: + - `@auth(profileValidation: Boolean, emailVerification: Boolean)` directive on `OBJECT | FIELD_DEFINITION` + - `@hasPermission(permission: String!)` directive on `OBJECT | FIELD_DEFINITION` + - `DateTime` scalar + - `JSON` scalar + - `Filters` input — recursive `AND: [Filters]`, `OR: [Filters]`, `not: Boolean`, `key: String`, `operator: String`, `value: String` + - `SortDirection` enum — `ASC | DESC` + - `SortInput` input — `key: String`, `direction: SortDirection` + - `DeleteResult` type — `result: Boolean!` + +## Re-exports + +13. **`mergeTypeDefs`** re-exported from `@graphql-tools/merge` — merges multiple `DocumentNode` or string schemas into one `DocumentNode`. + +14. **`gql`** tag re-exported from `graphql-tag` — parses GraphQL template literals into `DocumentNode`. + +15. **`DocumentNode`** type re-exported from `graphql`. diff --git a/packages/graphql/GUIDE.md b/packages/graphql/GUIDE.md new file mode 100644 index 000000000..b57d256ae --- /dev/null +++ b/packages/graphql/GUIDE.md @@ -0,0 +1,566 @@ +# @prefabs.tech/fastify-graphql — Developer Guide + +## Installation + +### For package consumers (npm + pnpm) + +```bash +# npm +npm install @prefabs.tech/fastify-graphql + +# pnpm +pnpm add @prefabs.tech/fastify-graphql +``` + +Peer dependencies you must also install: + +```bash +pnpm add fastify fastify-plugin graphql mercurius \ + @prefabs.tech/fastify-config @prefabs.tech/fastify-slonik \ + slonik zod +``` + +### For monorepo development (pnpm install / test / build) + +```bash +# from repo root +pnpm install + +# run tests for this package only +pnpm --filter @prefabs.tech/fastify-graphql test + +# build +pnpm --filter @prefabs.tech/fastify-graphql build +``` + +--- + +## Setup + +Register the plugin once, after `@prefabs.tech/fastify-config` and `@prefabs.tech/fastify-slonik` (the context builder reads from request decorators those plugins add). + +```typescript +import configPlugin from "@prefabs.tech/fastify-config"; +import slonikPlugin from "@prefabs.tech/fastify-slonik"; +import graphqlPlugin, { + baseSchema, + mergeTypeDefs, + gql, +} from "@prefabs.tech/fastify-graphql"; +import Fastify from "fastify"; + +const resolvers = { + Query: { + ping: async () => "pong", + }, +}; + +const schema = mergeTypeDefs([ + baseSchema, + gql` + type Query { + ping: String + } + `, +]); + +const fastify = Fastify({ logger: true }); + +await fastify.register(configPlugin, { config }); +await fastify.register(slonikPlugin, config.slonik); +await fastify.register(graphqlPlugin, { + enabled: true, + schema, + resolvers, +}); + +await fastify.listen({ port: 3000 }); +``` + +All later examples assume the Fastify instance is set up as above. + +--- + +## Base Libraries + +### mercurius — Partial Passthrough + +Docs: https://mercurius.dev / https://www.npmjs.com/package/mercurius + +The plugin wraps `mercurius` and passes the full options object through to `mercurius.register()`. We add two extra option fields (`enabled` and `plugins`) and provide a default context-building function. Because options are spread after that default, a caller-provided `context` still takes precedence. + +What we add on top: + +- The `enabled` guard — mercurius is only registered when `enabled` is truthy. +- A config-fallback path — reads `fastify.config.graphql` when no options are provided directly. +- A `buildContext` factory that seeds the Mercurius context with `config`, `database`, and `dbSchema`, then calls each `GraphqlEnabledPlugin.updateContext` in order. + +### @graphql-tools/merge — Full Passthrough + +Docs: https://the-guild.dev/graphql/tools/docs/schema-merging / https://www.npmjs.com/package/@graphql-tools/merge + +`mergeTypeDefs` is re-exported unchanged. No modifications. + +### graphql-tag — Full Passthrough + +Docs: https://www.npmjs.com/package/graphql-tag + +`gql` is re-exported unchanged. No modifications. + +### graphql — Type Re-export Only + +Docs: https://graphql.org/graphql-js/ / https://www.npmjs.com/package/graphql + +Only the `DocumentNode` type is re-exported. No runtime code from this library is executed by us. + +--- + +## Features + +### 1. Registration lifecycle logging + +The plugin emits lifecycle logs during setup so registration mode is visible in startup logs: + +- `info`: `"Registering fastify-graphql plugin"` when registration begins +- `warn`: fallback warning when no options are passed directly +- `info`: `"GraphQL API not enabled"` when `enabled` is falsy + +```typescript +await fastify.register(graphqlPlugin, config.graphql); +// logs: "Registering fastify-graphql plugin" +``` + +### 2. Conditional mercurius registration + +When `enabled` is falsy, the plugin logs `"GraphQL API not enabled"` and returns without registering mercurius. No `/graphql` route is mounted. + +```typescript +await fastify.register(graphqlPlugin, { + enabled: process.env.GRAPHQL_ENABLED !== "false", + schema, + resolvers, +}); +// When enabled is false, POST /graphql → 404 +``` + +### 3. Config fallback + +When the options object passed to `register()` is empty, the plugin falls back to `fastify.config.graphql`. It logs a deprecation-style warning. If `fastify.config.graphql` is also not present, it throws: + +> `"Missing graphql configuration. Did you forget to pass it to the graphql plugin?"` + +```typescript +// Direct options (preferred): +await fastify.register(graphqlPlugin, config.graphql); + +// Config fallback (legacy — triggers a warn log): +// fastify.config.graphql must be set by @prefabs.tech/fastify-config +await fastify.register(graphqlPlugin); +``` + +### 4. Default context factory + +When mercurius is registered, this package sets `context: buildContext(options.plugins)` as the default context function. Because `...options` is spread afterward, a caller-provided `context` can override this default. Use `plugins` when you want to extend the package-provided context path. + +```typescript +await fastify.register(graphqlPlugin, { + enabled: true, + schema, + resolvers, + plugins: [myContextPlugin], // preferred extension point + // context: customContext // optional override if you need full control +}); +``` + +### 5. Automatic context building (default path) + +When using the package's default context factory, three fields are injected into the Mercurius context from the Fastify request object automatically — no resolver-level wiring required. + +| Context field | Source | +| ------------------ | -------------------------------------------------------- | +| `context.config` | `request.config` (from `@prefabs.tech/fastify-config`) | +| `context.database` | `request.slonik` (from `@prefabs.tech/fastify-slonik`) | +| `context.dbSchema` | `request.dbSchema` (from `@prefabs.tech/fastify-slonik`) | + +```typescript +const resolvers = { + Query: { + user: async (_parent, { id }, context) => { + // context.config, context.database, context.dbSchema are ready to use + return context.database.pool.one( + context.database.sql` + SELECT * FROM ${context.database.sql.identifier([context.dbSchema, "users"])} + WHERE id = ${id} + `, + ); + }, + }, +}; +``` + +### 6. Plugin-based context extension + +Pass an array of `GraphqlEnabledPlugin` objects in the `plugins` option. When the default context factory is active, each plugin's `updateContext(context, request, reply)` method is called on every GraphQL request, in array order, after the base context is built. Plugins can add any fields they need to the shared context. + +```typescript +import type { FastifyInstance } from "fastify"; +import type { MercuriusContext } from "mercurius"; +import FastifyPlugin from "fastify-plugin"; +import type { GraphqlEnabledPlugin } from "@prefabs.tech/fastify-graphql"; + +// Augment the MercuriusContext type so resolvers get type safety +declare module "mercurius" { + interface MercuriusContext { + currentUser: User | null; + } +} + +const authPlugin = FastifyPlugin(async (fastify: FastifyInstance) => { + // Fastify-level setup (decorators, hooks, etc.) +}) as unknown as GraphqlEnabledPlugin; + +// Called once per GraphQL request +authPlugin.updateContext = async (context, request, _reply) => { + context.currentUser = request.user ?? null; +}; + +export default authPlugin; +``` + +Register it: + +```typescript +await fastify.register(graphqlPlugin, { + enabled: true, + plugins: [authPlugin], + schema, + resolvers, +}); +``` + +### 7. `GraphqlConfig` interface + +Extends `MercuriusOptions` with two plugin-specific fields: + +```typescript +import type { GraphqlConfig } from "@prefabs.tech/fastify-graphql"; + +const graphqlConfig: GraphqlConfig = { + enabled: true, // our addition — guards mercurius registration + plugins: [authPlugin], // our addition — context-extending plugins + schema, // passed through to mercurius + resolvers, // passed through to mercurius + graphiql: false, // passed through to mercurius +}; +``` + +### 8. `GraphqlOptions` type alias + +`GraphqlOptions` is an alias for `GraphqlConfig`. Use whichever name feels more natural in your codebase. + +```typescript +import type { GraphqlOptions } from "@prefabs.tech/fastify-graphql"; + +const opts: GraphqlOptions = { enabled: true, schema, resolvers }; +``` + +### 9. `GraphqlEnabledPlugin` interface + +A Fastify plugin (sync or async) that also carries a mandatory `updateContext` method. Implement this interface to create plugins that extend the GraphQL request context. + +```typescript +import type { GraphqlEnabledPlugin } from "@prefabs.tech/fastify-graphql"; +import type { MercuriusContext } from "mercurius"; +import FastifyPlugin from "fastify-plugin"; + +const myPlugin = FastifyPlugin(async (fastify) => { + fastify.decorate("myService", new MyService()); +}) as unknown as GraphqlEnabledPlugin; + +myPlugin.updateContext = async (context: MercuriusContext, request, reply) => { + context.myValue = await computeSomething(request); +}; +``` + +### 10. `MercuriusContext` augmentation + +This package extends the global `mercurius` module's `MercuriusContext` interface with three typed fields. Importing the package is enough for TypeScript to pick up the augmentation in your resolvers. + +```typescript +// Automatically available after importing the package: +// context.config → ApiConfig +// context.database → Database +// context.dbSchema → string + +const resolvers = { + Query: { + appInfo: (_parent, _args, context) => ({ + name: context.config.appName, // typed as string + env: context.config.env, // typed as string + }), + }, +}; +``` + +### 11. `ApiConfig` augmentation + +Importing this package extends `@prefabs.tech/fastify-config`'s `ApiConfig` interface with a `graphql` property typed as `GraphqlConfig`. This allows `fastify.config.graphql` to be fully typed throughout your application. + +```typescript +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +// After importing @prefabs.tech/fastify-graphql, ApiConfig gains: +// graphql: GraphqlConfig + +const config: ApiConfig = { + graphql: { + enabled: true, + schema, + resolvers, + }, + // ... other fields +}; +``` + +### 12. `baseSchema` export + +A pre-built `DocumentNode` with common GraphQL primitives ready to merge into your application schema. Use `mergeTypeDefs` to combine it with your own type definitions. + +```typescript +import { baseSchema, mergeTypeDefs, gql } from "@prefabs.tech/fastify-graphql"; +import { makeExecutableSchema } from "@graphql-tools/schema"; + +const appTypeDefs = gql` + type Query { + users(filters: Filters, sort: [SortInput]): [User!]! + deleteUser(id: ID!): DeleteResult + } + + type User { + id: ID! + email: String! + createdAt: DateTime! + metadata: JSON + } +`; + +const schema = makeExecutableSchema({ + typeDefs: mergeTypeDefs([baseSchema, appTypeDefs]), +}); +``` + +Provided by `baseSchema`: + +| Name | Kind | Description | +| ---------------- | --------- | ---------------------------------------------------------------------------------------- | +| `@auth` | directive | `profileValidation: Boolean`, `emailVerification: Boolean` on OBJECT or FIELD_DEFINITION | +| `@hasPermission` | directive | `permission: String!` on OBJECT or FIELD_DEFINITION | +| `DateTime` | scalar | Date/time values | +| `JSON` | scalar | Arbitrary JSON values | +| `Filters` | input | Recursive `AND`/`OR` filter tree with `key`, `operator`, `value` | +| `SortDirection` | enum | `ASC` or `DESC` | +| `SortInput` | input | `key: String`, `direction: SortDirection` | +| `DeleteResult` | type | `result: Boolean!` | + +### 13. `mergeTypeDefs` re-export + +`mergeTypeDefs` from `@graphql-tools/merge` is re-exported so you do not need a separate import. Merges an array of `DocumentNode` objects or SDL strings into a single `DocumentNode`. + +```typescript +import { baseSchema, mergeTypeDefs, gql } from "@prefabs.tech/fastify-graphql"; + +const merged = mergeTypeDefs([ + baseSchema, + gql` + type Query { + ping: String + } + `, + `type Mutation { noop: Boolean }`, +]); +``` + +### 14. `gql` tag re-export + +`gql` from `graphql-tag` is re-exported so you do not need a separate import. Parses a tagged template literal into a `DocumentNode`. + +```typescript +import { gql } from "@prefabs.tech/fastify-graphql"; + +const userTypeDefs = gql` + type User { + id: ID! + email: String! + } +`; +``` + +### 15. `DocumentNode` type re-export + +The `DocumentNode` type from the `graphql` package is re-exported for use in function signatures and type annotations without adding a direct `graphql` dependency to your code. + +```typescript +import type { DocumentNode } from "@prefabs.tech/fastify-graphql"; + +function buildSchema(extra: DocumentNode[]): DocumentNode { + return mergeTypeDefs([baseSchema, ...extra]); +} +``` + +--- + +## Use Cases + +### Use Case 1: Minimal GraphQL API + +Stand up a GraphQL endpoint with just `enabled`, `schema`, and `resolvers`. + +```typescript +import graphqlPlugin, { gql } from "@prefabs.tech/fastify-graphql"; +import Fastify from "fastify"; + +const fastify = Fastify({ logger: true }); + +await fastify.register(graphqlPlugin, { + enabled: true, + schema: gql` + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: async () => "world", + }, + }, +}); + +await fastify.listen({ port: 3000 }); +``` + +### Use Case 2: Full app setup with config, database, and auth context + +Realistic production setup: config plugin seeds `fastify.config`, slonik plugin seeds `request.slonik` and `request.dbSchema`, a custom auth plugin extends context with the current user. + +```typescript +import configPlugin from "@prefabs.tech/fastify-config"; +import slonikPlugin from "@prefabs.tech/fastify-slonik"; +import graphqlPlugin, { + baseSchema, + mergeTypeDefs, + gql, + type GraphqlEnabledPlugin, +} from "@prefabs.tech/fastify-graphql"; +import FastifyPlugin from "fastify-plugin"; +import Fastify from "fastify"; +import type { MercuriusContext } from "mercurius"; + +// --- Auth plugin --- +declare module "mercurius" { + interface MercuriusContext { + currentUser: User | null; + } +} + +const authPlugin = FastifyPlugin(async (fastify) => { + fastify.addHook("onRequest", async (request) => { + // decode JWT, set request.user + }); +}) as unknown as GraphqlEnabledPlugin; + +authPlugin.updateContext = async (context, request) => { + context.currentUser = request.user ?? null; +}; + +// --- Schema & resolvers --- +const appTypeDefs = gql` + type Query { + me: User + users(filters: Filters): [User!]! + } + + type User { + id: ID! + email: String! + createdAt: DateTime! + } +`; + +const resolvers = { + Query: { + me: (_parent, _args, context) => context.currentUser, + users: async (_parent, { filters }, context) => + userService.find(context.database, context.dbSchema, filters), + }, +}; + +// --- App bootstrap --- +const fastify = Fastify({ logger: config.logger }); + +await fastify.register(configPlugin, { config }); +await fastify.register(slonikPlugin, config.slonik); +await fastify.register(authPlugin); +await fastify.register(graphqlPlugin, { + ...config.graphql, + schema: mergeTypeDefs([baseSchema, appTypeDefs]), + resolvers, + plugins: [authPlugin], +}); + +await fastify.listen({ port: config.port }); +``` + +### Use Case 3: Feature-flag GraphQL off in non-production environments + +```typescript +const graphqlConfig = { + enabled: process.env.GRAPHQL_ENABLED === "true", + schema, + resolvers, +}; + +await fastify.register(graphqlPlugin, graphqlConfig); +// When GRAPHQL_ENABLED is not "true", /graphql returns 404 +``` + +### Use Case 4: Composing schemas from multiple modules + +Each feature module exports its own type definitions and resolvers, merged at startup. + +```typescript +import { baseSchema, mergeTypeDefs, gql } from "@prefabs.tech/fastify-graphql"; +import { makeExecutableSchema } from "@graphql-tools/schema"; + +import { + typeDefs as userTypeDefs, + resolvers as userResolvers, +} from "./modules/user"; +import { + typeDefs as orderTypeDefs, + resolvers as orderResolvers, +} from "./modules/order"; + +const schema = makeExecutableSchema({ + typeDefs: mergeTypeDefs([baseSchema, userTypeDefs, orderTypeDefs]), + resolvers: [userResolvers, orderResolvers], +}); + +await fastify.register(graphqlPlugin, { enabled: true, schema }); +``` + +### Use Case 5: Multiple context-extending plugins in order + +```typescript +import { authPlugin } from "./plugins/auth"; +import { tenantPlugin } from "./plugins/tenant"; +import { auditPlugin } from "./plugins/audit"; + +await fastify.register(graphqlPlugin, { + enabled: true, + schema, + resolvers, + // Each plugin's updateContext runs in this order on every request: + plugins: [authPlugin, tenantPlugin, auditPlugin], +}); +``` + +Each plugin receives the already-built context, so `tenantPlugin.updateContext` can read `context.currentUser` set by `authPlugin`, and `auditPlugin` can read both. diff --git a/packages/graphql/README.md b/packages/graphql/README.md index 5711f203d..350f3f0b5 100644 --- a/packages/graphql/README.md +++ b/packages/graphql/README.md @@ -4,12 +4,24 @@ A [Fastify](https://github.com/fastify/fastify) plugin that provides an easy int The plugin is a thin wrapper around the [mercurius](https://mercurius.dev/#/) plugin. +## Why this plugin? + +While registering `mercurius` directly perfectly enables GraphQL in a Fastify backend, enterprise APIs require deep context injection—such as database connections and application configurations—to function effectively within resolvers. We created this plugin to: + +- **Automate Context Injection**: Instead of manually building context objects on every request, this plugin automatically populates the `MercuriusContext` with the `fastify.config`, `slonik` database connection, and `dbSchema`, making them instantly and safely available to all your GraphQL resolvers out-of-the-box. +- **Unify Configuration**: By integrating seamlessly with `@prefabs.tech/fastify-config`, it ensures that your GraphQL schema paths, options, and boolean flags are strictly typed and managed in one centralized location. + +### Design Decisions: Why not Apollo Server or bare Mercurius? + +- **Why Mercurius instead of Apollo**: Mercurius is specifically built for Fastify, leveraging Fastify's lifecycle hooks to deliver significantly better performance and lower latency than typical Apollo setups. +- **Why intercept Mercurius**: Using bare Mercurius means maintaining your own context factory methods to inject database connections and app configurations recursively. By wrapping it, we enforce a standard, highly-typed context shape that is guaranteed to match the rest of our ecosystem, eliminating setup boilerplate completely. + ## Requirements -* [@prefabs.tech/fastify-config](../config/) -* [@prefabs.tech/fastify-slonik](../slonik/) -* [graphql](https://github.com/graphql/graphql-js) -* [mercurius](https://mercurius.dev/#/) +- [@prefabs.tech/fastify-config](../config/) +- [@prefabs.tech/fastify-slonik](../slonik/) +- [graphql](https://github.com/graphql/graphql-js) +- [mercurius](https://mercurius.dev/#/) ## Installation @@ -26,6 +38,7 @@ pnpm add --filter "@scope/project" @prefabs.tech/fastify-config @prefabs.tech/fa ``` ## Usage + To set up graphql in fastify project, follow these steps: Create a resolvers file at `src/graphql/resolvers.ts` to define all GraphQL mutations and queries. @@ -134,19 +147,20 @@ An additional `enabled` (boolean) option allows you to disable the graphql serve The fastify-graphql plugin will generate a graphql context on every request that will include the following attributes: -| Attribute | Type | Description | -|------------|------|-------------| -| `config` | `ApiConfig` | The fastify servers' config (as per [@prefabs.tech/fastify-config](../config/)) | +| Attribute | Type | Description | +| ---------- | ----------- | ---------------------------------------------------------------------------------------- | +| `config` | `ApiConfig` | The fastify servers' config (as per [@prefabs.tech/fastify-config](../config/)) | | `database` | `Database` | The fastify server's slonik instance (as per [@prefabs.tech/fastify-slonik](../slonik/)) | -| `dbSchema` | `string` | The database schema (as per [@prefabs.tech/fastify-slonik](../slonik/)) | +| `dbSchema` | `string` | The database schema (as per [@prefabs.tech/fastify-slonik](../slonik/)) | ## Supporting `.gql` files and external schema exports - To work with multiple schemas defined in `.gql` files or support GraphQL schema exports from external packages, ensure the following packages are installed in your API: -* [@graphql-tools/load](https://github.com/ardatan/graphql-tools/tree/master/packages/load) -* [@graphql-tools/load-files](https://github.com/ardatan/graphql-tools/tree/master/packages/load-files) -* [@graphql-tools/merge](https://github.com/ardatan/graphql-tools/tree/master/packages/merge) -* [@graphql-tools/schema](https://github.com/ardatan/graphql-tools/tree/master/packages/schema) +To work with multiple schemas defined in `.gql` files or support GraphQL schema exports from external packages, ensure the following packages are installed in your API: + +- [@graphql-tools/load](https://github.com/ardatan/graphql-tools/tree/master/packages/load) +- [@graphql-tools/load-files](https://github.com/ardatan/graphql-tools/tree/master/packages/load-files) +- [@graphql-tools/merge](https://github.com/ardatan/graphql-tools/tree/master/packages/merge) +- [@graphql-tools/schema](https://github.com/ardatan/graphql-tools/tree/master/packages/schema) To load and merge your GraphQL schemas, update your `src/graphql/schema.ts` file as follows: @@ -164,6 +178,7 @@ export default schema; ``` If you also need to include schemas defined in other packages update above code: + ```typescript import { graphqlSchema } from "example"; // example: importing schemas from external packages import { loadFilesSync } from "@graphql-tools/load-files"; @@ -177,6 +192,7 @@ const schema = makeExecutableSchema({ typeDefs }); export default schema; ``` + You can define additional schemas within the `src/` directory, including any nested subdirectories, using `.gql` files. For example, create a new file at `src/graphql/schema.gql`: ```graphql diff --git a/packages/graphql/eslint.config.js b/packages/graphql/eslint.config.js index 48a1291a4..7369a1f05 100644 --- a/packages/graphql/eslint.config.js +++ b/packages/graphql/eslint.config.js @@ -1,3 +1,22 @@ import fastifyConfig from "@prefabs.tech/eslint-config/fastify.js"; +import perfectionist from "eslint-plugin-perfectionist"; -export default fastifyConfig; +export default [ + ...fastifyConfig, + { + plugins: { + perfectionist, + }, + rules: { + // Disable conflicting default/import rules + "sort-imports": "off", + "import/order": "off", + + // Enable and spread Perfectionist's recommended rules + ...perfectionist.configs["recommended-alphabetical"].rules, + + // Add any Fastify-specific rule overrides here + "@typescript-eslint/no-explicit-any": "error", + }, + }, +]; diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 3e0931a18..d07b0f7d0 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -19,7 +19,9 @@ "main": "./dist/prefabs-tech-fastify-graphql.cjs", "module": "./dist/prefabs-tech-fastify-graphql.js", "types": "./dist/types/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", @@ -40,6 +42,7 @@ "@types/node": "24.10.13", "@vitest/coverage-istanbul": "3.2.4", "eslint": "9.39.2", + "eslint-plugin-perfectionist": "5.8.0", "fastify": "5.7.4", "fastify-plugin": "5.1.0", "graphql": "16.12.0", diff --git a/packages/graphql/src/__test__/baseSchema.test.ts b/packages/graphql/src/__test__/baseSchema.test.ts new file mode 100644 index 000000000..ba84b8061 --- /dev/null +++ b/packages/graphql/src/__test__/baseSchema.test.ts @@ -0,0 +1,19 @@ +import { print } from "graphql"; +import { describe, expect, it } from "vitest"; + +import baseSchema from "../baseSchema"; + +describe("baseSchema", () => { + it("includes documented directives, scalars, inputs, enum, and types", () => { + const printed = print(baseSchema); + + expect(printed).toContain("directive @auth"); + expect(printed).toContain("directive @hasPermission"); + expect(printed).toContain("scalar DateTime"); + expect(printed).toContain("scalar JSON"); + expect(printed).toContain("input Filters"); + expect(printed).toContain("enum SortDirection"); + expect(printed).toContain("input SortInput"); + expect(printed).toContain("type DeleteResult"); + }); +}); diff --git a/packages/graphql/src/__test__/context.spec.ts b/packages/graphql/src/__test__/context.spec.ts index def36cb9c..3d524c9c7 100644 --- a/packages/graphql/src/__test__/context.spec.ts +++ b/packages/graphql/src/__test__/context.spec.ts @@ -1,18 +1,22 @@ -import fastify from "fastify"; -import { describe, expect, it, beforeEach } from "vitest"; +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import graphqlPlugin from "../plugin"; import createConfig from "./helpers/createConfig"; import testPlugin from "./helpers/testPlugin"; import testPluginAsync from "./helpers/testPluginAsync"; -import type { FastifyInstance } from "fastify"; - -describe("Graphql Context", async () => { +describe("Graphql Context", () => { let api: FastifyInstance; - beforeEach(async () => { - api = await fastify(); + beforeEach(() => { + api = Fastify({ logger: false }); + }); + + afterEach(async () => { + await api.close(); }); it("Should add context property and value from callback test plugin", async () => { @@ -79,9 +83,9 @@ describe("Graphql Context", async () => { }); expect(JSON.parse(response.payload).data.test).toEqual({ + propertyOne: "Property One", //eslint-disable-next-line unicorn/no-null propertyTwo: null, - propertyOne: "Property One", }); expect(api).toHaveProperty(["propertyOne"], "Property One"); @@ -115,8 +119,8 @@ describe("Graphql Context", async () => { }); expect(JSON.parse(response.payload).data.test).toEqual({ - propertyTwo: "Property Two", propertyOne: "Property One", + propertyTwo: "Property Two", }); expect(api).toHaveProperty(["propertyTwo"], "Property Two"); diff --git a/packages/graphql/src/__test__/helpers/createConfig.ts b/packages/graphql/src/__test__/helpers/createConfig.ts index ed8927194..7b20d3bae 100644 --- a/packages/graphql/src/__test__/helpers/createConfig.ts +++ b/packages/graphql/src/__test__/helpers/createConfig.ts @@ -1,8 +1,9 @@ -/* istanbul ignore file */ -import type { GraphqlEnabledPlugin } from "../../types"; import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { MercuriusContext } from "mercurius"; +/* istanbul ignore file */ +import type { GraphqlEnabledPlugin } from "../../types"; + const schema = ` type Query { test: Response @@ -29,6 +30,14 @@ const createConfig = (plugins: GraphqlEnabledPlugin[]) => { appOrigin: ["http://localhost"], baseUrl: "http://localhost", env: "development", + graphql: { + enabled: true, + graphiql: false, + path: "/graphql", + plugins, + resolvers, + schema, + }, logger: { level: "debug", }, @@ -38,15 +47,6 @@ const createConfig = (plugins: GraphqlEnabledPlugin[]) => { rest: { enabled: true, }, - version: "0.1", - graphql: { - enabled: true, - graphiql: false, - path: "/graphql", - schema, - resolvers, - plugins, - }, slonik: { db: { databaseName: "test", @@ -55,6 +55,7 @@ const createConfig = (plugins: GraphqlEnabledPlugin[]) => { username: "username", }, }, + version: "0.1", }; return config; diff --git a/packages/graphql/src/__test__/helpers/testPlugin.ts b/packages/graphql/src/__test__/helpers/testPlugin.ts index 9f65d15bc..08bbbd01f 100644 --- a/packages/graphql/src/__test__/helpers/testPlugin.ts +++ b/packages/graphql/src/__test__/helpers/testPlugin.ts @@ -1,8 +1,9 @@ +import type { FastifyInstance } from "fastify"; +import type { MercuriusContext } from "mercurius"; + import FastifyPlugin from "fastify-plugin"; import type { GraphqlEnabledPlugin } from "../../types"; -import type { FastifyInstance } from "fastify"; -import type { MercuriusContext } from "mercurius"; declare module "mercurius" { interface MercuriusContext { diff --git a/packages/graphql/src/__test__/helpers/testPluginAsync.ts b/packages/graphql/src/__test__/helpers/testPluginAsync.ts index a2ab30601..a8fcaf1ec 100644 --- a/packages/graphql/src/__test__/helpers/testPluginAsync.ts +++ b/packages/graphql/src/__test__/helpers/testPluginAsync.ts @@ -1,8 +1,9 @@ +import type { FastifyInstance } from "fastify"; +import type { MercuriusContext } from "mercurius"; + import FastifyPlugin from "fastify-plugin"; import type { GraphqlEnabledPlugin } from "../../types"; -import type { FastifyInstance } from "fastify"; -import type { MercuriusContext } from "mercurius"; declare module "mercurius" { interface MercuriusContext { diff --git a/packages/graphql/src/__test__/plugin.test.ts b/packages/graphql/src/__test__/plugin.test.ts new file mode 100644 index 000000000..79e20b9e0 --- /dev/null +++ b/packages/graphql/src/__test__/plugin.test.ts @@ -0,0 +1,238 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import graphqlPlugin from "../plugin"; + +const schema = ` + type Query { + ping: String + } +`; + +const resolvers = { + Query: { + ping: async () => "pong", + }, +}; + +describe("graphqlPlugin — conditional registration", () => { + let fastify: FastifyInstance; + + afterEach(async () => { + await fastify.close(); + }); + + it("registers the /graphql route when enabled is true", async () => { + fastify = Fastify({ logger: false }); + await fastify.register(graphqlPlugin, { enabled: true, resolvers, schema }); + await fastify.ready(); + + const response = await fastify.inject({ + headers: { "content-type": "application/json" }, + method: "POST", + payload: JSON.stringify({ query: "{ ping }" }), + url: "/graphql", + }); + + expect(response.statusCode).toBe(200); + }); + + it("does not register /graphql route when enabled is false", async () => { + fastify = Fastify({ logger: false }); + await fastify.register(graphqlPlugin, { + enabled: false, + resolvers, + schema, + }); + await fastify.ready(); + + const response = await fastify.inject({ + method: "POST", + url: "/graphql", + }); + + expect(response.statusCode).toBe(404); + }); + + it("does not register /graphql when enabled is omitted", async () => { + fastify = Fastify({ logger: false }); + await fastify.register(graphqlPlugin, { resolvers, schema }); + await fastify.ready(); + + const response = await fastify.inject({ + method: "POST", + url: "/graphql", + }); + + expect(response.statusCode).toBe(404); + }); + + it("uses caller-provided context instead of the default factory when both are applicable", async () => { + fastify = Fastify({ logger: false }); + const customSchema = ` + type Query { + source: String + } + `; + const customResolvers = { + Query: { + source: async (_: unknown, __: unknown, context: unknown) => + (context as { source: string }).source, + }, + }; + + await fastify.register(graphqlPlugin, { + context: async () => ({ source: "custom-context" }), + enabled: true, + resolvers: customResolvers, + schema: customSchema, + }); + await fastify.ready(); + + const response = await fastify.inject({ + headers: { "content-type": "application/json" }, + method: "POST", + payload: JSON.stringify({ query: "{ source }" }), + url: "/graphql", + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.payload).data.source).toBe("custom-context"); + }); + + it("injects config, database, and dbSchema from the request into Mercurius context by default", async () => { + fastify = Fastify({ logger: false }); + const dumpSchema = ` + type Query { + dump: String + } + `; + const dumpResolvers = { + Query: { + dump: async (_: unknown, __: unknown, context: unknown) => + JSON.stringify({ + config: (context as { config: { marker: string } }).config.marker, + database: (context as { database: { marker: string } }).database + .marker, + dbSchema: (context as { dbSchema: string }).dbSchema, + }), + }, + }; + + fastify.addHook("onRequest", async (request) => { + request.config = { marker: "cfg" } as unknown as typeof request.config; + request.slonik = { marker: "db" } as unknown as typeof request.slonik; + request.dbSchema = "app_schema"; + }); + + await fastify.register(graphqlPlugin, { + enabled: true, + resolvers: dumpResolvers, + schema: dumpSchema, + }); + await fastify.ready(); + + const response = await fastify.inject({ + headers: { "content-type": "application/json" }, + method: "POST", + payload: JSON.stringify({ query: "{ dump }" }), + url: "/graphql", + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.payload).data.dump).toBe( + JSON.stringify({ + config: "cfg", + database: "db", + dbSchema: "app_schema", + }), + ); + }); +}); + +describe("graphqlPlugin — config fallback", () => { + let fastify: FastifyInstance; + + afterEach(async () => { + await fastify.close(); + }); + + it("reads options from fastify.config.graphql when no options are passed directly", async () => { + fastify = Fastify({ logger: false }); + fastify.decorate("config", { + graphql: { enabled: false, resolvers, schema }, + } as unknown as ApiConfig); + + await fastify.register(graphqlPlugin); + await fastify.ready(); + + // enabled: false in the fallback config means mercurius was not mounted + const response = await fastify.inject({ method: "POST", url: "/graphql" }); + expect(response.statusCode).toBe(404); + }); + + it("throws when no options are passed and fastify.config.graphql is undefined", async () => { + fastify = Fastify({ logger: false }); + fastify.decorate("config", {} as unknown as ApiConfig); + + const start = async () => { + await fastify.register(graphqlPlugin); + await fastify.ready(); + }; + + await expect(start()).rejects.toThrow("Missing graphql configuration"); + }); + + it("logs a warning when falling back to fastify.config.graphql", async () => { + fastify = Fastify({ logger: false }); + const warn = vi.spyOn(fastify.log, "warn").mockImplementation(() => {}); + fastify.decorate("config", { + graphql: { enabled: false, resolvers, schema }, + } as unknown as ApiConfig); + + await fastify.register(graphqlPlugin); + await fastify.ready(); + + expect(warn).toHaveBeenCalled(); + const warnedWithRecommendation = warn.mock.calls.some((call) => + call.some( + (argument) => + typeof argument === "string" && + argument.includes("passing graphql options directly"), + ), + ); + expect(warnedWithRecommendation).toBe(true); + }); +}); + +describe("graphqlPlugin — registration logging", () => { + let fastify!: FastifyInstance; + + afterEach(async () => { + await fastify.close(); + }); + + it("logs that GraphQL is disabled when enabled is false", async () => { + fastify = Fastify({ logger: false }); + const info = vi.spyOn(fastify.log, "info").mockImplementation(() => {}); + + await fastify.register(graphqlPlugin, { + enabled: false, + resolvers, + schema, + }); + await fastify.ready(); + + const loggedDisabled = info.mock.calls.some((call) => + call.some( + (argument) => + typeof argument === "string" && + argument.includes("GraphQL API not enabled"), + ), + ); + expect(loggedDisabled).toBe(true); + }); +}); diff --git a/packages/graphql/src/buildContext.ts b/packages/graphql/src/buildContext.ts index b19772352..bcf1dfad1 100644 --- a/packages/graphql/src/buildContext.ts +++ b/packages/graphql/src/buildContext.ts @@ -1,7 +1,8 @@ -import type { GraphqlEnabledPlugin } from "./types"; -import type { FastifyRequest, FastifyReply } from "fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; import type { MercuriusContext } from "mercurius"; +import type { GraphqlEnabledPlugin } from "./types"; + const buildContext = (plugins?: GraphqlEnabledPlugin[]) => { return async (request: FastifyRequest, reply: FastifyReply) => { const context = { diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index 9d05a661f..540a46416 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -1,7 +1,8 @@ -import type { GraphqlConfig } from "./types"; import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Database } from "@prefabs.tech/fastify-slonik"; +import type { GraphqlConfig } from "./types"; + declare module "mercurius" { interface MercuriusContext { config: ApiConfig; @@ -16,14 +17,14 @@ declare module "@prefabs.tech/fastify-config" { } } -export { default } from "./plugin"; -export { gql } from "graphql-tag"; -export { mergeTypeDefs } from "@graphql-tools/merge"; export { default as baseSchema } from "./baseSchema"; - +export { default } from "./plugin"; export type { GraphqlConfig, GraphqlEnabledPlugin, GraphqlOptions, } from "./types"; +export { mergeTypeDefs } from "@graphql-tools/merge"; + export type { DocumentNode } from "graphql"; +export { gql } from "graphql-tag"; diff --git a/packages/graphql/src/plugin.ts b/packages/graphql/src/plugin.ts index 20fcc6037..ca8a2f0fc 100644 --- a/packages/graphql/src/plugin.ts +++ b/packages/graphql/src/plugin.ts @@ -1,10 +1,11 @@ +import type { FastifyInstance } from "fastify"; + import FastifyPlugin from "fastify-plugin"; import { mercurius } from "mercurius"; -import buildContext from "./buildContext"; - import type { GraphqlOptions } from "./types"; -import type { FastifyInstance } from "fastify"; + +import buildContext from "./buildContext"; const plugin = async (fastify: FastifyInstance, options: GraphqlOptions) => { fastify.log.info("Registering fastify-graphql plugin"); diff --git a/packages/graphql/src/types.ts b/packages/graphql/src/types.ts index 8ce2c3544..be39b9d9a 100644 --- a/packages/graphql/src/types.ts +++ b/packages/graphql/src/types.ts @@ -1,8 +1,8 @@ import type { - FastifyPluginCallback, FastifyPluginAsync, - FastifyRequest, + FastifyPluginCallback, FastifyReply, + FastifyRequest, } from "fastify"; import type { MercuriusContext, MercuriusOptions } from "mercurius"; diff --git a/packages/graphql/tsconfig.json b/packages/graphql/tsconfig.json index 50005d55b..1628077b9 100644 --- a/packages/graphql/tsconfig.json +++ b/packages/graphql/tsconfig.json @@ -1,9 +1,9 @@ { "extends": "@prefabs.tech/tsconfig/fastify.json", + "exclude": ["src/**/__test__/**/*"], "compilerOptions": { - "outDir": "./dist", + "baseUrl": "./", + "outDir": "./dist" }, - "include": [ - "src/**/*.ts" - ] + "include": ["src/**/*.ts"] } diff --git a/packages/graphql/vite.config.ts b/packages/graphql/vite.config.ts index 6af9117f1..a002d82b7 100644 --- a/packages/graphql/vite.config.ts +++ b/packages/graphql/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { dependencies, peerDependencies } from "./package.json"; @@ -25,9 +24,9 @@ export default defineConfig(({ mode }) => { output: { exports: "named", globals: { + "@graphql-tools/merge": "GraphqlToolsMerge", "@prefabs.tech/fastify-config": "PrefabsTechFastifyConfig", "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", - "@graphql-tools/merge": "GraphqlToolsMerge", fastify: "Fastify", "fastify-plugin": "FastifyPlugin", graphql: "Graphql", diff --git a/packages/mailer/ANALYSIS.md b/packages/mailer/ANALYSIS.md new file mode 100644 index 000000000..5ba176761 --- /dev/null +++ b/packages/mailer/ANALYSIS.md @@ -0,0 +1,116 @@ + + +# @prefabs.tech/fastify-mailer — Analysis + +## Base Library Passthrough Analysis + +### nodemailer — PARTIAL PASSTHROUGH + +- Options type: custom `MailerConfig` with `transport: SMTPOptions` and `defaults: { from } & Partial` +- Options passed: transformed before send + - `createTransport(transport, defaults)` is direct passthrough at registration time. + - Outgoing `sendMail` input is wrapped to merge template data and optionally override recipients. +- Features restricted: none at transport level; delivery payload is modified when `recipients` is configured. +- Features added: + - Fastify decorator (`fastify.mailer`) + - Global + per-email `templateData` merge + - Optional recipient redirection (`to` override, `cc`/`bcc` cleared) + - Optional callback-aware wrapper + +### nodemailer-mjml — PARTIAL PASSTHROUGH + +- Options type: `templating: IPluginOptions` in our config type +- Options passed: transformed — only `{ templateFolder: templating.templateFolder }` is forwarded to `nodemailerMjmlPlugin` +- Features restricted: other `IPluginOptions` keys are not forwarded +- Features added: integrated compile hook registration inside plugin startup + +### nodemailer-html-to-text — MODIFIED + +- Options type: none exposed +- Options passed: transformed — always called as `htmlToText()` with default options +- Features restricted: no way to configure plugin options from `MailerConfig` +- Features added: automatic compile hook registration after MJML compile hook + +### mjml — MODIFIED + +- Options type: none exposed +- Options passed: transformed — `mjml2html()` is used only in the optional test route with an inline MJML template string +- Features restricted: not configurable through plugin options +- Features added: built-in HTTP test endpoint that sends a compiled test email + +### fastify-plugin — FULL PASSTHROUGH + +- Options type: not applicable +- Options passed: unmodified plugin wrapper (`FastifyPlugin(plugin)`) +- Features restricted: none +- Features added: encapsulation bypass for decorated `fastify.mailer` + +## Function/Export Classification (Ours vs Theirs) + +- `default export from src/plugin.ts` (`FastifyPlugin(plugin)`) — **OURS** + - Our registration logic, decorators, wrappers, and conditionals are inside `plugin`. + - Wrapper call to `fastify-plugin` is third-party integration. +- `default export from src/index.ts` (re-export plugin) — **OURS** + - Public package entrypoint and module augmentation. +- `FastifyMailer` (type alias) — **OURS** (extends third-party `Transporter`) +- `FastifyMailerNamedInstance` (interface) — **OURS** +- `MailerConfig` / `MailerOptions` (config shape) — **OURS** (composes third-party option types) +- `router` (`src/router.ts`) — **OURS** + - Registers conditional test endpoint and response payload contract. +- `testEmailSchema` (`src/schema.ts`) — **OURS** + - Defines schema for test route responses and OpenAPI metadata. + +## Fastify Integrations + +### Decorators Added + +- `fastify.mailer` — decorated once after transport setup +- Guard: throws `"fastify-mailer has already been registered"` if already present + +### Hooks/Routes Registered + +- Nodemailer `"compile"` hook with `nodemailerMjmlPlugin({ templateFolder })` +- Nodemailer `"compile"` hook with `htmlToText()` +- Conditional Fastify `GET` route at `test.path` (when `test?.enabled`) + +## Conditional Branches + +- **Legacy config fallback** + - Condition: `Object.keys(options).length === 0` + - Behavior: warn + read `fastify.config.mailer` + - Error path: throws if `fastify.config.mailer` missing +- **Template data merge** + - Starts with empty object + - Merges global config `templateData` if present + - Merges per-email `userOptions.templateData` last (wins on key conflicts) +- **Recipient override** + - Condition: `recipients && recipients.length > 0` + - Behavior: force `to = recipients`, set `cc` and `bcc` to `undefined` +- **Callback path** + - Condition: callback provided to `sendMail` + - Behavior: calls `transporter.sendMail(mailerOptions, callback)`; otherwise promise path +- **Test route registration** + - Condition: `test && test.enabled` + - Behavior: register internal router with `{ path, to }` + +## Default Values and Implicit Defaults + +- `templateData` defaults to `{}` inside wrapped `sendMail` +- `cc` and `bcc` are explicitly set to `undefined` only in recipient-override mode +- Test route is disabled by default (when `test` is missing or `enabled` is false) +- `htmlToText()` runs with library defaults (no options passed) + +## Notes from Existing Docs and Tests + +- Existing `FEATURES.md` and `GUIDE.md` largely match implementation. +- One important nuance from source: `templating` is typed as `IPluginOptions`, but only `templateFolder` is currently forwarded. +- Tests verify registration flow, template-data merge precedence, recipient override behavior, and conditional test route behavior. + +## Completeness Checklist + +- [x] Classified every public export as "ours" or "theirs" +- [x] Listed every Fastify decorator added +- [x] Listed every hook registered +- [x] Identified every conditional branch +- [x] Documented default values for options we define +- [x] Produced passthrough classification for every wrapped dependency diff --git a/packages/mailer/FEATURES.md b/packages/mailer/FEATURES.md new file mode 100644 index 000000000..70bc81cbb --- /dev/null +++ b/packages/mailer/FEATURES.md @@ -0,0 +1,77 @@ + + +# @prefabs.tech/fastify-mailer — Features + +## Plugin Registration + +1. **Registration info log** — logs `info: Registering fastify-mailer plugin` on startup. + +2. **Duplicate registration guard** — throws `"fastify-mailer has already been registered"` if you register the plugin twice on the same Fastify instance. + +3. **Config fallback (legacy mode)** — when no options are passed to `register()`, reads config from `fastify.config.mailer` (requires `@prefabs.tech/fastify-config`). Emits a deprecation warning. + +4. **Missing config error** — when neither inline options nor `fastify.config.mailer` is present, throws a descriptive error: + + ``` + Error: Missing mailer configuration. Did you forget to pass it to the mailer plugin? + ``` + +5. **Fastify encapsulation bypass** — wrapped with `fastify-plugin` so `fastify.mailer` is available in all child plugins without re-registering. + +## Transport + +6. **SMTP transport creation** — calls `nodemailer.createTransport(transport, defaults)` to create the transporter. Compatible with any SMTP provider (AWS SES, Mailgun, SendGrid, Gmail, etc.). + +7. **Default sender via `defaults.from`** — `defaults.from.address` and `defaults.from.name` are applied as global sender defaults to every email. Additional nodemailer `Options` (e.g., `replyTo`) can also be set under `defaults`. + +## Compile-time Middleware + +8. **MJML compile hook** — registers `nodemailerMjmlPlugin` on the nodemailer `"compile"` lifecycle with the configured `templateFolder`. Enables `.mjml` template files to be resolved, compiled to HTML, and interpolated before delivery. + +9. **Auto HTML-to-text conversion** — registers `nodemailer-html-to-text` on the `"compile"` lifecycle after MJML. Automatically generates a plain-text `text` part from `html` for every email. + +## `fastify.mailer` Decorator + +10. **Transporter-backed `fastify.mailer` decorator** — `fastify.mailer` is built from the created nodemailer transporter and overrides `sendMail` with plugin-specific behavior (template data merge + optional recipient override). + +11. **Promise-based `sendMail`** — resolves with nodemailer's `SentMessageInfo`. + +12. **Callback-based `sendMail`** — accepts an optional Node.js-style callback as the second argument. + +## Template Data + +13. **Global template data** — set `templateData` in plugin options to provide variables available in every email template without passing them per-email. + +14. **Per-email template data** — pass `templateData` directly on each `sendMail` call for email-specific variables. + +15. **Template data merge with override precedence** — per-email `templateData` is shallow-merged over the global config `templateData`. Per-email values win on key conflicts. The global object is never mutated. + +## Recipient Override + +16. **Redirect all emails to fixed addresses** — when `recipients` is a non-empty array, every outgoing email is redirected to those addresses regardless of the `to` field. `cc` and `bcc` are explicitly cleared to `undefined`. + +## Test Infrastructure + +17. **Conditional HTTP test route** — when `test.enabled` is `true`, registers a `GET` route at `test.path` that sends a test email to `test.to`. Omit `test` or set `test.enabled: false` to skip. Route returns: + + ```json + { + "status": "ok", + "message": "Email successfully sent", + "info": { "from": "...", "to": "..." } + } + ``` + +18. **Inline MJML compilation in test route** — the test email body is compiled inline via `mjml2html()`, not via the template folder mechanism. + +19. **JSON Schema validation on test route** — 200 and 500 responses are validated against registered JSON schemas. + +20. **OpenAPI tagging on test route** — tagged `["email"]` with summary `"Test email"` for Swagger/OpenAPI tools. + +## TypeScript Integration + +21. **`FastifyInstance` module augmentation** — importing the plugin adds `mailer: FastifyMailer` to Fastify's instance type automatically. + +22. **`ApiConfig` module augmentation** — importing the plugin extends `@prefabs.tech/fastify-config`'s `ApiConfig` with `mailer: MailerConfig`. + +23. **Exported types** — `FastifyMailer`, `FastifyMailerNamedInstance`, and `MailerConfig` are exported for use in application code. diff --git a/packages/mailer/GUIDE.md b/packages/mailer/GUIDE.md new file mode 100644 index 000000000..f7c96760f --- /dev/null +++ b/packages/mailer/GUIDE.md @@ -0,0 +1,592 @@ +# @prefabs.tech/fastify-mailer — Developer Guide + +## Installation + +### For package consumers (npm + pnpm) + +```bash +# npm +npm install @prefabs.tech/fastify-mailer nodemailer mjml fastify fastify-plugin + +# pnpm +pnpm add @prefabs.tech/fastify-mailer nodemailer mjml fastify fastify-plugin +``` + +Peer dependencies that must be installed alongside the package: + +| Peer dependency | Required version | +| ------------------------------ | -------------------------------------------------------- | +| `fastify` | `>=5.2.1` | +| `fastify-plugin` | `>=5.0.1` | +| `mjml` | `>=4.15.3` | +| `@prefabs.tech/fastify-config` | `0.93.5` (optional — only needed for legacy config mode) | + +### For monorepo development (pnpm install / test / build) + +```bash +# From repo root — installs all workspace dependencies +pnpm install + +# Run tests for this package only +pnpm --filter @prefabs.tech/fastify-mailer test + +# Build +pnpm --filter @prefabs.tech/fastify-mailer build + +# Type-check +pnpm --filter @prefabs.tech/fastify-mailer typecheck +``` + +## Setup + +Complete working example. All later examples in this guide assume this setup is in place. + +```typescript +import Fastify from "fastify"; +import mailerPlugin from "@prefabs.tech/fastify-mailer"; +// Importing the package automatically augments FastifyInstance and ApiConfig types. +import "@prefabs.tech/fastify-mailer"; + +const fastify = Fastify({ logger: true }); + +await fastify.register(mailerPlugin, { + transport: { + host: "smtp.example.com", + port: 587, + secure: false, + requireTLS: true, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }, + defaults: { + from: { + address: "noreply@myapp.com", + name: "My App", + }, + }, + templating: { + templateFolder: "./src/email-templates", + }, + // Optional: global template variables injected into every email + templateData: { + appName: "My App", + supportEmail: "support@myapp.com", + }, + // Optional: redirect all emails during development/staging + // recipients: ["dev@myapp.com"], + + // Optional: enable a test route at startup to verify mail delivery + // test: { enabled: true, path: "/test/email", to: "dev@myapp.com" }, +}); + +await fastify.ready(); +``` + +--- + +## Base Libraries + +### nodemailer — Partial Passthrough + +Their docs: https://www.npmjs.com/package/nodemailer + +`nodemailer.createTransport()` is called internally with the `transport` and `defaults` options you provide. `fastify.mailer` is built from that transporter, but `sendMail` is wrapped by this package before delivery. + +What we add on top: + +- We wrap `sendMail` to inject template data (global + per-email merge) before forwarding to the underlying transporter. +- When `recipients` is configured, we intercept the `to`, `cc`, and `bcc` fields before the call reaches nodemailer. +- `createTransport` and the raw `Transporter` are never directly exposed — access is always through `fastify.mailer`. + +### nodemailer-mjml — Partial Passthrough + +Their docs: https://www.npmjs.com/package/nodemailer-mjml + +The plugin is registered on nodemailer's `"compile"` lifecycle hook with the `templateFolder` you provide. We currently forward only `templateFolder` to `nodemailerMjmlPlugin`, even though `templating` is typed as `IPluginOptions`. + +### nodemailer-html-to-text — Modified + +Their docs: https://www.npmjs.com/package/nodemailer-html-to-text + +Registered on nodemailer's `"compile"` lifecycle hook after MJML (so it operates on already-compiled HTML). We always call `htmlToText()` with no options and do not expose configuration. + +### mjml — Modified + +Their docs: https://www.npmjs.com/package/mjml + +Used only inside the built-in test route to compile a hardcoded MJML snippet inline via `mjml2html()`. Application code that uses template files through nodemailer-mjml does not interact with this dependency directly. + +### fastify-plugin — Full Passthrough + +Their docs: https://www.npmjs.com/package/fastify-plugin + +The entire plugin is wrapped with `FastifyPlugin()` to opt out of Fastify's encapsulation scope. This means the `fastify.mailer` decorator is available in all child plugins and routes without re-registering. + +--- + +## Features + +### 1. Plugin registration info log + +When the plugin initialises it writes an `info`-level log entry: + +``` +Registering fastify-mailer plugin +``` + +No configuration required. + +### 2. Duplicate registration guard + +Registering the plugin twice on the same Fastify instance throws synchronously: + +```typescript +await fastify.register(mailerPlugin, options); // OK +await fastify.register(mailerPlugin, options); // throws "fastify-mailer has already been registered" +``` + +### 3. Config fallback (legacy mode) + +If you call `register()` with no options, the plugin looks for `fastify.config.mailer`. This requires the `@prefabs.tech/fastify-config` package to be registered first and is deprecated in favour of passing options directly. + +```typescript +import configPlugin from "@prefabs.tech/fastify-config"; +import mailerPlugin from "@prefabs.tech/fastify-mailer"; + +// fastify-config populates fastify.config.mailer from environment / config file +await fastify.register(configPlugin); + +// mailerPlugin reads fastify.config.mailer automatically +await fastify.register(mailerPlugin); +``` + +When this path is taken the plugin logs a warning: + +``` +The mailer plugin now recommends passing mailer options directly to the plugin. +``` + +### 4. Missing config error + +When neither inline options nor `fastify.config.mailer` are present: + +```typescript +await fastify.register(mailerPlugin); +// Error: Missing mailer configuration. Did you forget to pass it to the mailer plugin? +``` + +### 5. Fastify encapsulation bypass + +Because the plugin is wrapped with `fastify-plugin`, the `fastify.mailer` decorator is visible to the entire server — including sibling plugins and parent scopes — without additional registration. + +```typescript +await fastify.register(mailerPlugin, options); + +fastify.register(async (childPlugin) => { + // fastify.mailer is accessible here without re-registering + await childPlugin.mailer.sendMail({ + to: "user@example.com", + subject: "Hi", + html: "

Hello

", + }); +}); +``` + +### 6. SMTP transport creation + +The `transport` option is passed verbatim to `nodemailer.createTransport()` alongside `defaults`. Any SMTP provider supported by nodemailer works. + +```typescript +await fastify.register(mailerPlugin, { + transport: { + host: "email-smtp.us-east-1.amazonaws.com", + port: 465, + secure: true, + auth: { user: process.env.SES_USER, pass: process.env.SES_PASS }, + }, + defaults: { from: { address: "no-reply@myapp.com", name: "My App" } }, + templating: { templateFolder: "./templates" }, +}); +``` + +### 7. Default sender via `defaults.from` + +`defaults.from` must contain an `address` and a `name`. These are used as the `From` header on every outgoing email. Any additional nodemailer `Options` (such as `replyTo`) can also live under `defaults`. + +```typescript +await fastify.register(mailerPlugin, { + // ... + defaults: { + from: { + address: "noreply@myapp.com", + name: "My App", + }, + replyTo: "support@myapp.com", + }, + // ... +}); +``` + +### 8. MJML compile hook + +On plugin startup, `nodemailerMjmlPlugin({ templateFolder })` is registered on nodemailer's `"compile"` lifecycle. Name your templates `.mjml` inside `templateFolder`. Reference them by name in `sendMail` calls via the `templateName` field (a nodemailer-mjml convention). + +```typescript +// Template at: ./src/email-templates/welcome.mjml +await fastify.mailer.sendMail({ + to: "user@example.com", + subject: "Welcome", + templateName: "welcome", // nodemailer-mjml resolves this to the .mjml file + templateData: { firstName: "Ada" }, // variables injected into the template +}); +``` + +See the [nodemailer-mjml docs](https://www.npmjs.com/package/nodemailer-mjml) for the full template syntax. + +### 9. Auto HTML-to-text conversion + +A plain-text `text` part is automatically generated from the `html` content of every email. No configuration is required and nothing needs to be set in `sendMail` calls. The conversion runs after MJML compilation. + +### 10. Transporter-backed `fastify.mailer` decorator + +`fastify.mailer` is created from the nodemailer transporter and adds a wrapped `sendMail` implementation that injects plugin behavior (templateData merge + optional recipient override): + +```typescript +await fastify.mailer.sendMail({ + to: "user@example.com", + subject: "Hello", + html: "

Hello

", +}); +``` + +### 11. Promise-based `sendMail` + +```typescript +const info = await fastify.mailer.sendMail({ + to: "user@example.com", + subject: "Order confirmation", + html: "

Your order has shipped.

", +}); +console.log("Message ID:", info.messageId); +``` + +### 12. Callback-based `sendMail` + +```typescript +fastify.mailer.sendMail( + { to: "user@example.com", subject: "Hi", html: "

Hello

" }, + (err, info) => { + if (err) return console.error(err); + console.log("Sent:", info.response); + }, +); +``` + +### 13. Global template data + +Set `templateData` at registration time to inject variables into every template without repeating them on every `sendMail` call. + +```typescript +await fastify.register(mailerPlugin, { + // ... + templateData: { + appName: "My App", + year: new Date().getFullYear(), + supportEmail: "support@myapp.com", + }, +}); +``` + +### 14. Per-email template data + +Pass `templateData` on individual `sendMail` calls for data specific to that email. + +```typescript +await fastify.mailer.sendMail({ + to: "user@example.com", + subject: "Your order", + templateName: "order-confirmation", + templateData: { + orderId: "ORD-001", + total: "$49.99", + }, +}); +``` + +### 15. Template data merge with override precedence + +Global `templateData` and per-email `templateData` are shallow-merged. Per-email values win on key conflicts. The global object is never mutated between calls. + +```typescript +// Registration: templateData = { appName: "My App", env: "production" } +// sendMail call: templateData = { env: "staging", orderId: "ORD-1" } +// Effective templateData passed to the template: +// { appName: "My App", env: "staging", orderId: "ORD-1" } +``` + +### 16. Redirect all emails to fixed addresses + +Set `recipients` to a non-empty array to force all outgoing emails to those addresses. The original `to`, `cc`, and `bcc` fields are overwritten or cleared. Useful for staging environments to prevent sending to real users. + +```typescript +await fastify.register(mailerPlugin, { + // ... + recipients: ["qa@myapp.com", "staging-monitor@myapp.com"], +}); + +// Even though `to` is a real user address, email goes only to recipients above. +await fastify.mailer.sendMail({ + to: "real-customer@example.com", + cc: "manager@example.com", + subject: "Order shipped", + html: "

Your order is on the way.

", +}); +// Delivered to: qa@myapp.com, staging-monitor@myapp.com +// cc and bcc: undefined +``` + +When `recipients` is an empty array or omitted, the original `to`, `cc`, and `bcc` values pass through unchanged. + +### 17. Conditional HTTP test route + +Enable a `GET` endpoint that sends a live test email and returns a JSON confirmation. Useful for smoke-testing SMTP connectivity in deployed environments. + +```typescript +await fastify.register(mailerPlugin, { + // ... + test: { + enabled: true, // set to false or omit `test` entirely to disable + path: "/internal/test-email", + to: "ops-team@myapp.com", + }, +}); + +// GET /internal/test-email +// Response: +// { "status": "ok", "message": "Email successfully sent", "info": { "from": "...", "to": "..." } } +``` + +### 18. Inline MJML compilation in test route + +The test route builds and compiles its email body inline using `mjml2html()`. It does not depend on the `templateFolder` configured for the application — no template files need to exist for the test route to work. + +### 19. JSON Schema validation on test route + +The test route declares response schemas for both 200 (success) and 500 (error) status codes. These are enforced by Fastify's built-in validation and serialisation. + +| Status | Required fields | +| ------ | ------------------------------- | +| 200 | `status`, `message`, `info` | +| 500 | `message`, `name`, `statusCode` | + +### 20. OpenAPI tagging on test route + +The test route is tagged `["email"]` with summary `"Test email"`. If you use `@fastify/swagger` or a compatible plugin, the route appears in the generated spec automatically. + +### 21. `FastifyInstance` module augmentation + +Importing `@prefabs.tech/fastify-mailer` extends Fastify's `FastifyInstance` interface with `mailer: FastifyMailer`. This gives full TypeScript type-checking on `fastify.mailer` and its methods. + +```typescript +// The augmentation happens automatically on import — no extra steps needed. +import "@prefabs.tech/fastify-mailer"; + +// fastify.mailer is now typed as FastifyMailer everywhere +const info = await fastify.mailer.sendMail({ ... }); +``` + +### 22. `ApiConfig` module augmentation + +Importing the plugin also extends `@prefabs.tech/fastify-config`'s `ApiConfig` interface with `mailer: MailerConfig`. This makes `fastify.config.mailer` fully typed when using the config plugin. + +```typescript +import "@prefabs.tech/fastify-mailer"; +// fastify.config.mailer is now typed as MailerConfig +``` + +### 23. Exported types + +Three TypeScript types are exported for use in application code: + +```typescript +import type { + FastifyMailer, + FastifyMailerNamedInstance, + MailerConfig, +} from "@prefabs.tech/fastify-mailer"; + +// Type a function that accepts the mailer +function scheduleEmail(mailer: FastifyMailer, to: string): Promise { + return mailer.sendMail({ to, subject: "Scheduled", html: "

Hi

" }); +} + +// Type your config object before passing to register() +const mailerConfig: MailerConfig = { + transport: { host: "smtp.example.com", port: 587 }, + defaults: { from: { address: "noreply@myapp.com", name: "My App" } }, + templating: { templateFolder: "./templates" }, +}; +``` + +--- + +## Use Cases + +### Use Case 1: Transactional email with an MJML template + +Send a styled HTML email using a `.mjml` file stored in the template folder. Global template data (year, brand name) is set once at registration; per-call data (recipient name, order ID) is provided when sending. + +```typescript +// ./src/email-templates/order-confirmation.mjml +// +// +// +// Hello {{firstName}}, your order {{orderId}} has been placed. +// © {{year}} {{appName}} +// +// +// + +import Fastify from "fastify"; +import mailerPlugin from "@prefabs.tech/fastify-mailer"; + +const fastify = Fastify({ logger: true }); + +await fastify.register(mailerPlugin, { + transport: { + host: "smtp.mailgun.org", + port: 587, + auth: { user: process.env.MG_USER!, pass: process.env.MG_PASS! }, + }, + defaults: { from: { address: "orders@myapp.com", name: "My App Orders" } }, + templating: { templateFolder: "./src/email-templates" }, + templateData: { + appName: "My App", + year: new Date().getFullYear(), + }, +}); + +await fastify.ready(); + +// In a route handler: +fastify.post("/orders", async (request, reply) => { + const { customerEmail, firstName, orderId } = request.body as { + customerEmail: string; + firstName: string; + orderId: string; + }; + + await fastify.mailer.sendMail({ + to: customerEmail, + subject: `Order ${orderId} confirmed`, + templateName: "order-confirmation", + templateData: { firstName, orderId }, + }); + + reply.send({ ok: true }); +}); +``` + +### Use Case 2: Staging environment recipient redirect + +Redirect all emails to an internal team inbox during staging so real users are never contacted. The `recipients` array completely replaces `to`, `cc`, and `bcc` on every outgoing email. + +```typescript +import Fastify from "fastify"; +import mailerPlugin from "@prefabs.tech/fastify-mailer"; + +const fastify = Fastify({ logger: true }); +const isStaging = process.env.NODE_ENV === "staging"; + +await fastify.register(mailerPlugin, { + transport: { + host: process.env.SMTP_HOST!, + port: 587, + auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASS! }, + }, + defaults: { from: { address: "noreply@myapp.com", name: "My App" } }, + templating: { templateFolder: "./src/email-templates" }, + // Redirect all mail when on staging + ...(isStaging && { recipients: ["staging-inbox@myapp.com"] }), +}); +``` + +### Use Case 3: SMTP connectivity smoke test + +Enable the built-in test route to confirm that the SMTP connection is working after a deployment. Hit the endpoint once and check the response. + +```typescript +import Fastify from "fastify"; +import mailerPlugin from "@prefabs.tech/fastify-mailer"; + +const fastify = Fastify({ logger: true }); + +await fastify.register(mailerPlugin, { + transport: { + host: process.env.SMTP_HOST!, + port: 587, + auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASS! }, + }, + defaults: { from: { address: "noreply@myapp.com", name: "My App" } }, + templating: { templateFolder: "./src/email-templates" }, + test: { + enabled: process.env.ENABLE_MAIL_TEST_ROUTE === "true", + path: "/internal/smoke/email", + to: process.env.SMOKE_TEST_EMAIL ?? "ops@myapp.com", + }, +}); + +await fastify.ready(); +await fastify.listen({ port: 3000 }); + +// After startup: +// curl http://localhost:3000/internal/smoke/email +// → { "status": "ok", "message": "Email successfully sent", "info": { ... } } +``` + +### Use Case 4: Legacy config-driven setup via `@prefabs.tech/fastify-config` + +If your application already uses `@prefabs.tech/fastify-config` to manage all configuration from environment variables or a config file, you can register `mailerPlugin` without arguments and let it read from `fastify.config.mailer`. + +```typescript +import Fastify from "fastify"; +import configPlugin from "@prefabs.tech/fastify-config"; +import mailerPlugin from "@prefabs.tech/fastify-mailer"; +// Augments ApiConfig with mailer: MailerConfig +import "@prefabs.tech/fastify-mailer"; + +const fastify = Fastify({ logger: true }); + +// configPlugin reads env vars / config files and populates fastify.config +await fastify.register(configPlugin); + +// mailerPlugin finds its config at fastify.config.mailer automatically +await fastify.register(mailerPlugin); + +await fastify.ready(); +``` + +Note: this mode logs a deprecation warning. The recommended approach is to pass `MailerConfig` directly to `register()`. + +### Use Case 5: Sending email with a callback + +Use the Node.js-style callback API when integrating with legacy code that does not use Promises. + +```typescript +fastify.mailer.sendMail( + { + to: "user@example.com", + subject: "Account created", + html: "

Welcome aboard!

", + }, + (err, info) => { + if (err) { + fastify.log.error({ err }, "Failed to send welcome email"); + return; + } + fastify.log.info({ messageId: info.messageId }, "Welcome email sent"); + }, +); +``` diff --git a/packages/mailer/README.md b/packages/mailer/README.md index e4f5d3af4..f3d97f0e7 100644 --- a/packages/mailer/README.md +++ b/packages/mailer/README.md @@ -2,6 +2,18 @@ A [Fastify](https://github.com/fastify/fastify) plugin that when registered on a Fastify instance, will decorate it with a `mailer` object for email. +## Why this plugin? + +Sending production-ready emails is significantly more complex than just piping strings into NodeMailer. You must compile responsive HTML, define generic fallback text, inject dynamic payload data, and manage transport credentials securely. We created this plugin to: + +- **Unify the Mailing Pipeline**: It bundles `nodemailer`, `mustache` (for variable templating), `nodemailer-mjml` (for converting elegant MJML components to cross-client compatible HTML), and `html-to-text` into a single, cohesive processing pipeline. +- **Provide a Centralized Decorator**: By decorating the Fastify instance with a structured `mailer` object, dispatching rich emails from anywhere within your application is simplified to a single, type-safe API call. + +### Design Decisions: Why wrap Nodemailer instead of using external Saas SDKs? + +1. **Vendor Agnosticism**: Directly integrating SDKs like SendGrid or Postmark locks your application architecture. By wrapping NodeMailer natively, you can instantly pivot between different SMTP servers (e.g., AWS SES, Mailgun, or standard SMTP) just by updating `config/mailer.ts` without touching any business logic. +2. **Template Independence**: We chose MJML and Mustache for templates to keep your email designs purely structural and inside your repository. This eliminates the dependency on third-party drag-and-drop editors and ensures your email templates are rigorously version-controlled alongside your application logic. + ## Requirements - [html-to-text](https://github.com/html-to-text/node-html-to-text) @@ -15,13 +27,13 @@ A [Fastify](https://github.com/fastify/fastify) plugin that when registered on a Install with npm: ```bash -npm install @prefabs.tech/fastify-mailer html-to-text mustache nodemailer nodemailer nodemailer-html-to-text nodemailer-mjml +npm install @prefabs.tech/fastify-mailer html-to-text mustache nodemailer nodemailer-html-to-text nodemailer-mjml ``` Install with pnpm: ```bash -pnpm add --filter "@scope/project" @prefabs.tech/fastify-mailer html-to-text mustache nodemailer nodemailer nodemailer-html-to-text nodemailer-mjml +pnpm add --filter "@scope/project" @prefabs.tech/fastify-mailer html-to-text mustache nodemailer nodemailer-html-to-text nodemailer-mjml ``` ## Usage @@ -41,13 +53,13 @@ const start = async () => { const fastify = Fastify({ logger: config.logger, }); - + // Register mailer plugin await fastify.register(mailerPlugin, config.mailer); - + await fastify.listen({ - port: config.port, host: "0.0.0.0", + port: config.port, }); }; @@ -55,6 +67,7 @@ start(); ``` ## Configuration + To configure the mailer, add the following settings to your `config/mailer.ts` file: ```typescript diff --git a/packages/mailer/eslint.config.js b/packages/mailer/eslint.config.js index 48a1291a4..7369a1f05 100644 --- a/packages/mailer/eslint.config.js +++ b/packages/mailer/eslint.config.js @@ -1,3 +1,22 @@ import fastifyConfig from "@prefabs.tech/eslint-config/fastify.js"; +import perfectionist from "eslint-plugin-perfectionist"; -export default fastifyConfig; +export default [ + ...fastifyConfig, + { + plugins: { + perfectionist, + }, + rules: { + // Disable conflicting default/import rules + "sort-imports": "off", + "import/order": "off", + + // Enable and spread Perfectionist's recommended rules + ...perfectionist.configs["recommended-alphabetical"].rules, + + // Add any Fastify-specific rule overrides here + "@typescript-eslint/no-explicit-any": "error", + }, + }, +]; diff --git a/packages/mailer/package.json b/packages/mailer/package.json index 55e505741..40b5f9b0c 100644 --- a/packages/mailer/package.json +++ b/packages/mailer/package.json @@ -19,7 +19,9 @@ "main": "./dist/prefabs-tech-fastify-mailer.cjs", "module": "./dist/prefabs-tech-fastify-mailer.js", "types": "./dist/types/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", @@ -45,6 +47,7 @@ "@types/nodemailer-html-to-text": "3.1.3", "@vitest/coverage-istanbul": "3.2.4", "eslint": "9.39.2", + "eslint-plugin-perfectionist": "5.8.0", "fastify": "5.7.4", "fastify-plugin": "5.1.0", "mjml": "4.18.0", diff --git a/packages/mailer/src/__test__/helpers/createMailerConfig.ts b/packages/mailer/src/__test__/helpers/createMailerConfig.ts index 6cf0362dc..8d82067b1 100644 --- a/packages/mailer/src/__test__/helpers/createMailerConfig.ts +++ b/packages/mailer/src/__test__/helpers/createMailerConfig.ts @@ -6,9 +6,9 @@ const createMailerConfig = () => { name: "Mailer Team", }, }, - test: { enabled: true, path: "/test/email", to: "receiver@example.com" }, - templating: { templateFolder: "mjml/templates" }, templateData: { exampleUrl: "http://localhost:2000/" }, + templating: { templateFolder: "mjml/templates" }, + test: { enabled: true, path: "/test/email", to: "receiver@example.com" }, transport: { auth: { pass: "pass", user: "user" }, host: "localhost", diff --git a/packages/mailer/src/__test__/mailer.spec.ts b/packages/mailer/src/__test__/mailer.spec.ts deleted file mode 100644 index 29ff0fe1d..000000000 --- a/packages/mailer/src/__test__/mailer.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import fastify from "fastify"; -import { describe, expect, it, vi, beforeEach } from "vitest"; - -import createMailerConfig from "./helpers/createMailerConfig"; - -import type { FastifyInstance } from "fastify"; - -const nodemailerMjmlPluginMock = vi.fn(); -const htmlToTextMock = vi.fn(); -const useMock = vi.fn(); -const sendMailMock = vi.fn().mockResolvedValue({ response: "250 OK" }); -const createTransportMock = vi.fn().mockReturnValue({ - sendMail: sendMailMock, - use: useMock, -}); - -vi.mock("nodemailer", () => ({ - createTransport: createTransportMock, -})); - -vi.mock("nodemailer-mjml", () => ({ - nodemailerMjmlPlugin: nodemailerMjmlPluginMock, -})); - -vi.mock("nodemailer-html-to-text", () => ({ - htmlToText: htmlToTextMock, -})); - -describe("Mailer", async () => { - let api: FastifyInstance; - - const { default: plugin } = await import("../plugin"); - - beforeEach(async () => { - api = await fastify(); - - api.decorate("config", { mailer: createMailerConfig() }); - }); - - it("Create Mailer instance with ", async () => { - const { transport, defaults, templating } = createMailerConfig(); - await api.register(plugin, createMailerConfig()); - - expect(createTransportMock).toHaveBeenCalledWith(transport, defaults); - - expect(useMock).toHaveBeenCalledWith("compile", nodemailerMjmlPluginMock()); - - expect(useMock).toHaveBeenCalledWith("compile", htmlToTextMock()); - - expect(nodemailerMjmlPluginMock).toHaveBeenCalledWith({ - templateFolder: templating.templateFolder, - }); - }); - - it("Should throw error if mailer already registered to api", async () => { - await api.register(plugin, createMailerConfig()); - - await expect( - api.register(plugin, createMailerConfig()), - ).rejects.toThrowError("fastify-mailer has already been registered"); - }); - - it("Should call SendMail method ", async () => { - const { - templateData, - test: { path, to }, - } = createMailerConfig(); - - await api.register(plugin, createMailerConfig()); - - await api.inject({ - method: "GET", - path: path, - }); - - expect(sendMailMock).toHaveBeenCalledWith({ - html: expect.stringContaining(""), - subject: "Test email", - to: to, - templateData: templateData, - }); - }); -}); diff --git a/packages/mailer/src/__test__/recipients.test.ts b/packages/mailer/src/__test__/recipients.test.ts new file mode 100644 index 000000000..1dc2d38c0 --- /dev/null +++ b/packages/mailer/src/__test__/recipients.test.ts @@ -0,0 +1,151 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import "../index"; +import createMailerConfig from "./helpers/createMailerConfig"; + +const { createTransportMock, sendMailMock } = vi.hoisted(() => { + const useMock = vi.fn(); + const sendMailMock = vi.fn().mockResolvedValue({ response: "250 OK" }); + const createTransportMock = vi.fn().mockReturnValue({ + sendMail: sendMailMock, + use: useMock, + }); + return { createTransportMock, sendMailMock }; +}); + +vi.mock("nodemailer", () => ({ + createTransport: createTransportMock, +})); + +vi.mock("nodemailer-mjml", () => ({ + nodemailerMjmlPlugin: vi.fn(), +})); + +vi.mock("nodemailer-html-to-text", () => ({ + htmlToText: vi.fn(), +})); + +const baseMailOptions = { + bcc: "bcc@example.com", + cc: "cc@example.com", + html: "

Hi

", + subject: "Test", + to: "user@example.com", +}; + +describe("mailerPlugin — recipient override", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("when recipients is not configured", () => { + beforeEach(async () => { + fastify = Fastify({ logger: false }); + const config = createMailerConfig(); + delete (config as { recipients?: unknown }).recipients; + await fastify.register(plugin, config); + await fastify.ready(); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("passes the original to through unchanged", async () => { + await fastify.mailer.sendMail(baseMailOptions); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.to).toBe("user@example.com"); + }); + + it("passes the original cc through unchanged", async () => { + await fastify.mailer.sendMail(baseMailOptions); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.cc).toBe("cc@example.com"); + }); + + it("passes the original bcc through unchanged", async () => { + await fastify.mailer.sendMail(baseMailOptions); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.bcc).toBe("bcc@example.com"); + }); + }); + + describe("when recipients is an empty array", () => { + beforeEach(async () => { + fastify = Fastify({ logger: false }); + await fastify.register(plugin, { + ...createMailerConfig(), + recipients: [], + }); + await fastify.ready(); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("does not override to", async () => { + await fastify.mailer.sendMail(baseMailOptions); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.to).toBe("user@example.com"); + }); + + it("does not clear cc", async () => { + await fastify.mailer.sendMail(baseMailOptions); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.cc).toBe("cc@example.com"); + }); + }); + + describe("when recipients array is configured", () => { + const recipients = ["qa@myapp.com", "staging@myapp.com"]; + + beforeEach(async () => { + fastify = Fastify({ logger: false }); + await fastify.register(plugin, { + ...createMailerConfig(), + recipients, + }); + await fastify.ready(); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("overrides to with the recipients array", async () => { + await fastify.mailer.sendMail(baseMailOptions); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.to).toEqual(recipients); + }); + + it("sets cc to undefined", async () => { + await fastify.mailer.sendMail(baseMailOptions); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.cc).toBeUndefined(); + }); + + it("sets bcc to undefined", async () => { + await fastify.mailer.sendMail(baseMailOptions); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.bcc).toBeUndefined(); + }); + + it("still delivers to recipients even when to was different", async () => { + await fastify.mailer.sendMail({ + ...baseMailOptions, + to: "real-customer@example.com", + }); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.to).toEqual(recipients); + expect(calledWith.to).not.toContain("real-customer@example.com"); + }); + }); +}); diff --git a/packages/mailer/src/__test__/registration.test.ts b/packages/mailer/src/__test__/registration.test.ts new file mode 100644 index 000000000..b067dc920 --- /dev/null +++ b/packages/mailer/src/__test__/registration.test.ts @@ -0,0 +1,169 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import "../index"; +import createMailerConfig from "./helpers/createMailerConfig"; + +const { createTransportMock, useMock } = vi.hoisted(() => { + const useMock = vi.fn(); + const sendMailMock = vi.fn().mockResolvedValue({ response: "250 OK" }); + const createTransportMock = vi.fn().mockReturnValue({ + sendMail: sendMailMock, + use: useMock, + }); + return { createTransportMock, useMock }; +}); + +vi.mock("nodemailer", () => ({ + createTransport: createTransportMock, +})); + +vi.mock("nodemailer-mjml", () => ({ + nodemailerMjmlPlugin: vi.fn(), +})); + +vi.mock("nodemailer-html-to-text", () => ({ + htmlToText: vi.fn(), +})); + +describe("mailerPlugin — registration", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("registers without throwing", async () => { + await expect( + fastify.register(plugin, createMailerConfig()), + ).resolves.not.toThrow(); + }); + + it("decorates fastify with mailer after registration", async () => { + await fastify.register(plugin, createMailerConfig()); + await fastify.ready(); + expect(fastify.mailer).toBeDefined(); + }); + + it("fastify.mailer exposes sendMail", async () => { + await fastify.register(plugin, createMailerConfig()); + await fastify.ready(); + expect(typeof fastify.mailer.sendMail).toBe("function"); + }); + + it("calls createTransport with transport and defaults", async () => { + const { defaults, transport } = createMailerConfig(); + await fastify.register(plugin, createMailerConfig()); + expect(createTransportMock).toHaveBeenCalledWith(transport, defaults); + }); + + it("registers MJML compile hook with configured templateFolder", async () => { + const { nodemailerMjmlPlugin } = await import("nodemailer-mjml"); + const { templating } = createMailerConfig(); + await fastify.register(plugin, createMailerConfig()); + expect(nodemailerMjmlPlugin).toHaveBeenCalledWith({ + templateFolder: templating.templateFolder, + }); + expect(useMock).toHaveBeenCalledWith( + "compile", + (nodemailerMjmlPlugin as ReturnType)(), + ); + }); + + it("registers html-to-text compile hook", async () => { + const { htmlToText } = await import("nodemailer-html-to-text"); + await fastify.register(plugin, createMailerConfig()); + expect(useMock).toHaveBeenCalledWith( + "compile", + (htmlToText as ReturnType)(), + ); + }); + + it("throws when registered twice on the same instance", async () => { + await fastify.register(plugin, createMailerConfig()); + await expect( + fastify.register(plugin, createMailerConfig()), + ).rejects.toThrow("fastify-mailer has already been registered"); + }); + + it("logs an info message when the plugin starts registering", async () => { + await fastify.close(); + fastify = Fastify({ logger: { level: "silent" } }); + const infoSpy = vi.spyOn(fastify.log, "info"); + await fastify.register(plugin, createMailerConfig()); + await fastify.ready(); + expect(infoSpy).toHaveBeenCalledWith("Registering fastify-mailer plugin"); + }); + + it("exposes fastify.mailer inside nested plugin registrations without re-registering", async () => { + await fastify.register(plugin, createMailerConfig()); + let nestedHasMailer = false; + await fastify.register(async (instance) => { + nestedHasMailer = typeof instance.mailer?.sendMail === "function"; + }); + await fastify.ready(); + expect(nestedHasMailer).toBe(true); + }); +}); + +describe("mailerPlugin — legacy config fallback", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("reads options from fastify.config.mailer when no options passed to register()", async () => { + const config = createMailerConfig(); + fastify.decorate("config", { mailer: config }); + + await fastify.register(plugin); + await fastify.ready(); + + expect(createTransportMock).toHaveBeenCalledWith( + config.transport, + config.defaults, + ); + }); + + it("fastify.mailer is available after legacy registration", async () => { + fastify.decorate("config", { mailer: createMailerConfig() }); + await fastify.register(plugin); + await fastify.ready(); + expect(fastify.mailer).toBeDefined(); + }); + + it("throws a descriptive error when no options and no fastify.config.mailer", async () => { + await expect(fastify.register(plugin)).rejects.toThrow( + "Missing mailer configuration. Did you forget to pass it to the mailer plugin?", + ); + }); + + it("warns when falling back to fastify.config.mailer", async () => { + await fastify.close(); + fastify = Fastify({ logger: { level: "silent" } }); + const warnSpy = vi.spyOn(fastify.log, "warn"); + fastify.decorate("config", { mailer: createMailerConfig() }); + await fastify.register(plugin); + await fastify.ready(); + expect(warnSpy).toHaveBeenCalledWith( + "The mailer plugin now recommends passing mailer options directly to the plugin.", + ); + }); +}); diff --git a/packages/mailer/src/__test__/schema.test.ts b/packages/mailer/src/__test__/schema.test.ts new file mode 100644 index 000000000..fcdfbf973 --- /dev/null +++ b/packages/mailer/src/__test__/schema.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { testEmailSchema } from "../schema"; + +describe("testEmailSchema", () => { + it("tags the test email route for OpenAPI tools", () => { + expect(testEmailSchema.tags).toEqual(["email"]); + }); + + it("documents a summary for the test email route", () => { + expect(testEmailSchema.summary).toBe("Test email"); + }); + + it("requires status, message, and info on successful responses", () => { + const ok = testEmailSchema.response[200]; + expect(ok.required).toEqual(["status", "message", "info"]); + }); +}); diff --git a/packages/mailer/src/__test__/sendMail.test.ts b/packages/mailer/src/__test__/sendMail.test.ts new file mode 100644 index 000000000..380abd1cd --- /dev/null +++ b/packages/mailer/src/__test__/sendMail.test.ts @@ -0,0 +1,214 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import "../index"; +import createMailerConfig from "./helpers/createMailerConfig"; + +const { createTransportMock, sendMailMock } = vi.hoisted(() => { + const useMock = vi.fn(); + const sendMailMock = vi.fn().mockResolvedValue({ response: "250 OK" }); + const createTransportMock = vi.fn().mockReturnValue({ + sendMail: sendMailMock, + use: useMock, + }); + return { createTransportMock, sendMailMock }; +}); + +vi.mock("nodemailer", () => ({ + createTransport: createTransportMock, +})); + +vi.mock("nodemailer-mjml", () => ({ + nodemailerMjmlPlugin: vi.fn(), +})); + +vi.mock("nodemailer-html-to-text", () => ({ + htmlToText: vi.fn(), +})); + +describe("mailerPlugin — sendMail › template data", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("passes empty templateData when neither global nor per-email is set", async () => { + const config = createMailerConfig(); + delete (config as { templateData?: unknown }).templateData; + + await fastify.register(plugin, config); + await fastify.ready(); + + await fastify.mailer.sendMail({ + html: "

Hi

", + subject: "Test", + to: "user@example.com", + }); + + expect(sendMailMock).toHaveBeenCalledWith( + expect.objectContaining({ templateData: {} }), + ); + }); + + it("passes global templateData when no per-email templateData given", async () => { + const globalData = { appName: "MyApp", year: 2025 }; + await fastify.register(plugin, { + ...createMailerConfig(), + templateData: globalData, + }); + await fastify.ready(); + + await fastify.mailer.sendMail({ + html: "

Hi

", + subject: "Test", + to: "user@example.com", + }); + + expect(sendMailMock).toHaveBeenCalledWith( + expect.objectContaining({ templateData: globalData }), + ); + }); + + it("passes per-email templateData when no global templateData configured", async () => { + const config = createMailerConfig(); + delete (config as { templateData?: unknown }).templateData; + + await fastify.register(plugin, config); + await fastify.ready(); + + const perEmailData = { orderId: "ORD-001", total: "$9.99" }; + await fastify.mailer.sendMail({ + html: "

Confirmed

", + subject: "Order", + templateData: perEmailData, + to: "user@example.com", + }); + + expect(sendMailMock).toHaveBeenCalledWith( + expect.objectContaining({ templateData: perEmailData }), + ); + }); + + it("merges global and per-email templateData into a single object", async () => { + await fastify.register(plugin, { + ...createMailerConfig(), + templateData: { appName: "MyApp", supportEmail: "help@myapp.com" }, + }); + await fastify.ready(); + + await fastify.mailer.sendMail({ + html: "

Hi

", + subject: "Test", + templateData: { orderId: "ORD-123" }, + to: "user@example.com", + }); + + expect(sendMailMock).toHaveBeenCalledWith( + expect.objectContaining({ + templateData: { + appName: "MyApp", + orderId: "ORD-123", + supportEmail: "help@myapp.com", + }, + }), + ); + }); + + it("per-email templateData overrides global on key conflict", async () => { + await fastify.register(plugin, { + ...createMailerConfig(), + templateData: { appName: "MyApp", env: "production" }, + }); + await fastify.ready(); + + await fastify.mailer.sendMail({ + html: "

Hi

", + subject: "Test", + templateData: { env: "staging" }, + to: "user@example.com", + }); + + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.templateData.env).toBe("staging"); + expect(calledWith.templateData.appName).toBe("MyApp"); + }); + + it("resolves with the transporter sendMail result when using the promise API", async () => { + const sentInfo = { messageId: "", response: "250 OK" }; + sendMailMock.mockResolvedValueOnce(sentInfo); + + await fastify.register(plugin, createMailerConfig()); + await fastify.ready(); + + const result = await fastify.mailer.sendMail({ + html: "

Hi

", + subject: "Test", + to: "user@example.com", + }); + + expect(result).toEqual(sentInfo); + }); + + it("global templateData is not mutated by per-email overrides", async () => { + const globalData = { env: "production" }; + await fastify.register(plugin, { + ...createMailerConfig(), + templateData: globalData, + }); + await fastify.ready(); + + await fastify.mailer.sendMail({ + html: "

1

", + subject: "1", + templateData: { env: "staging" }, + to: "a@example.com", + }); + + await fastify.mailer.sendMail({ + html: "

2

", + subject: "2", + to: "b@example.com", + }); + + const secondCall = sendMailMock.mock.calls[1][0]; + expect(secondCall.templateData.env).toBe("production"); + }); +}); + +describe("mailerPlugin — sendMail › callback", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + await fastify.register(plugin, createMailerConfig()); + await fastify.ready(); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("invokes callback on success", async () => { + const callback = vi.fn(); + + await fastify.mailer.sendMail( + { html: "

Hi

", subject: "Hi", to: "user@example.com" }, + callback, + ); + + expect(sendMailMock).toHaveBeenCalledWith(expect.any(Object), callback); + }); +}); diff --git a/packages/mailer/src/__test__/testRoute.test.ts b/packages/mailer/src/__test__/testRoute.test.ts new file mode 100644 index 000000000..aaa6aff87 --- /dev/null +++ b/packages/mailer/src/__test__/testRoute.test.ts @@ -0,0 +1,162 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import "../index"; +import createMailerConfig from "./helpers/createMailerConfig"; + +const { createTransportMock, sendMailMock } = vi.hoisted(() => { + const useMock = vi.fn(); + const sendMailMock = vi.fn().mockResolvedValue({ response: "250 OK" }); + const createTransportMock = vi.fn().mockReturnValue({ + sendMail: sendMailMock, + use: useMock, + }); + return { createTransportMock, sendMailMock }; +}); + +vi.mock("nodemailer", () => ({ + createTransport: createTransportMock, +})); + +vi.mock("nodemailer-mjml", () => ({ + nodemailerMjmlPlugin: vi.fn(), +})); + +vi.mock("nodemailer-html-to-text", () => ({ + htmlToText: vi.fn(), +})); + +describe("mailerPlugin — test route › conditional registration", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (fastify) { + await fastify.close(); + } + }); + + it("does not register a route when test option is omitted", async () => { + fastify = Fastify({ logger: false }); + const config = createMailerConfig(); + delete (config as { test?: unknown }).test; + + await fastify.register(plugin, config); + await fastify.ready(); + + const res = await fastify.inject({ method: "GET", url: "/test/email" }); + expect(res.statusCode).toBe(404); + }); + + it("does not register a route when test.enabled is false", async () => { + fastify = Fastify({ logger: false }); + await fastify.register(plugin, { + ...createMailerConfig(), + test: { enabled: false, path: "/test/email", to: "dev@example.com" }, + }); + await fastify.ready(); + + const res = await fastify.inject({ method: "GET", url: "/test/email" }); + expect(res.statusCode).toBe(404); + }); + + it("registers a GET route at test.path when test.enabled is true", async () => { + fastify = Fastify({ logger: false }); + await fastify.register(plugin, { + ...createMailerConfig(), + test: { enabled: true, path: "/custom/test-mail", to: "dev@example.com" }, + }); + await fastify.ready(); + + const res = await fastify.inject({ + method: "GET", + url: "/custom/test-mail", + }); + expect(res.statusCode).not.toBe(404); + }); +}); + +describe("mailerPlugin — test route › HTTP response", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + const testConfig = createMailerConfig(); + + beforeEach(async () => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + await fastify.register(plugin, testConfig); + await fastify.ready(); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("GET test route returns status 200", async () => { + const res = await fastify.inject({ + method: "GET", + url: testConfig.test.path, + }); + expect(res.statusCode).toBe(200); + }); + + it("response body has status: ok", async () => { + const res = await fastify.inject({ + method: "GET", + url: testConfig.test.path, + }); + expect(res.json().status).toBe("ok"); + }); + + it("response body has message: Email successfully sent", async () => { + const res = await fastify.inject({ + method: "GET", + url: testConfig.test.path, + }); + expect(res.json().message).toBe("Email successfully sent"); + }); + + it("response body has an info object", async () => { + const res = await fastify.inject({ + method: "GET", + url: testConfig.test.path, + }); + expect(res.json().info).toBeDefined(); + expect(typeof res.json().info).toBe("object"); + }); + + it("sendMail is called with the configured test.to address", async () => { + await fastify.inject({ method: "GET", url: testConfig.test.path }); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.to).toBe(testConfig.test.to); + }); + + it("sendMail is called with subject Test email", async () => { + await fastify.inject({ method: "GET", url: testConfig.test.path }); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.subject).toBe("Test email"); + }); + + it("sendMail is called with compiled HTML content", async () => { + await fastify.inject({ method: "GET", url: testConfig.test.path }); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.html).toContain(""); + }); + + it("returns 500 when sendMail rejects", async () => { + sendMailMock.mockRejectedValueOnce(new Error("SMTP failure")); + const res = await fastify.inject({ + method: "GET", + url: testConfig.test.path, + }); + expect(res.statusCode).toBe(500); + }); +}); diff --git a/packages/mailer/src/index.ts b/packages/mailer/src/index.ts index d6cd42640..105f2c78a 100644 --- a/packages/mailer/src/index.ts +++ b/packages/mailer/src/index.ts @@ -1,7 +1,8 @@ -import type { FastifyMailer, MailerConfig } from "./types"; /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { FastifyMailer, MailerConfig } from "./types"; + declare module "fastify" { interface FastifyInstance { mailer: FastifyMailer; diff --git a/packages/mailer/src/plugin.ts b/packages/mailer/src/plugin.ts index e1f9b2b45..8d45a252f 100644 --- a/packages/mailer/src/plugin.ts +++ b/packages/mailer/src/plugin.ts @@ -1,14 +1,15 @@ +import type { FastifyInstance } from "fastify"; +import type { MailOptions } from "nodemailer/lib/sendmail-transport"; + import FastifyPlugin from "fastify-plugin"; import { createTransport } from "nodemailer"; -import SMTPTransport from "nodemailer/lib/smtp-transport"; import { htmlToText } from "nodemailer-html-to-text"; import { nodemailerMjmlPlugin } from "nodemailer-mjml"; - -import router from "./router"; +import SMTPTransport from "nodemailer/lib/smtp-transport"; import type { FastifyMailer, MailerOptions } from "./types"; -import type { FastifyInstance } from "fastify"; -import type { MailOptions } from "nodemailer/lib/sendmail-transport"; + +import router from "./router"; const plugin = async (fastify: FastifyInstance, options: MailerOptions) => { fastify.log.info("Registering fastify-mailer plugin"); @@ -18,7 +19,7 @@ const plugin = async (fastify: FastifyInstance, options: MailerOptions) => { "The mailer plugin now recommends passing mailer options directly to the plugin.", ); - if (!fastify.config.mailer) { + if (!fastify.config?.mailer) { throw new Error( "Missing mailer configuration. Did you forget to pass it to the mailer plugin?", ); @@ -29,11 +30,11 @@ const plugin = async (fastify: FastifyInstance, options: MailerOptions) => { const { defaults, + recipients, + templateData: configTemplateData, templating, test, transport, - templateData: configTemplateData, - recipients, } = options; const transporter = createTransport(transport, defaults); @@ -75,9 +76,9 @@ const plugin = async (fastify: FastifyInstance, options: MailerOptions) => { if (recipients && recipients.length > 0) { mailerOptions = { + ...mailerOptions, bcc: undefined, cc: undefined, - ...mailerOptions, to: recipients, }; } diff --git a/packages/mailer/src/router.ts b/packages/mailer/src/router.ts index a9b5af15d..c53fcce73 100644 --- a/packages/mailer/src/router.ts +++ b/packages/mailer/src/router.ts @@ -1,9 +1,9 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; + import mjml2html from "mjml"; import { testEmailSchema } from "./schema"; -import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; - const router = async ( fastify: FastifyInstance, options: { @@ -46,9 +46,9 @@ const router = async ( }); reply.send({ - status: "ok", - message: "Email successfully sent", info, + message: "Email successfully sent", + status: "ok", }); }, ); diff --git a/packages/mailer/src/schema.ts b/packages/mailer/src/schema.ts index e5fe080f5..551db9d99 100644 --- a/packages/mailer/src/schema.ts +++ b/packages/mailer/src/schema.ts @@ -1,41 +1,41 @@ const errorSchema = { - type: "object", + additionalProperties: true, properties: { code: { type: "string" }, error: { type: "string" }, message: { type: "string" }, name: { type: "string" }, stack: { - type: "array", items: { type: "object" }, + type: "array", }, statusCode: { type: "number" }, }, required: ["message", "name", "statusCode"], - additionalProperties: true, + type: "object", }; export const testEmailSchema = { response: { 200: { - type: "object", properties: { - status: { type: "string", const: "ok" }, - message: { type: "string", const: "Email successfully sent" }, info: { - type: "object", properties: { from: { type: "string" }, to: { type: "string" }, }, + type: "object", }, + message: { const: "Email successfully sent", type: "string" }, + status: { const: "ok", type: "string" }, }, required: ["status", "message", "info"], + type: "object", }, 500: { ...errorSchema, }, }, - tags: ["email"], summary: "Test email", + tags: ["email"], }; diff --git a/packages/mailer/src/types.ts b/packages/mailer/src/types.ts index b95b1f3d8..ba27e2929 100644 --- a/packages/mailer/src/types.ts +++ b/packages/mailer/src/types.ts @@ -1,21 +1,27 @@ import type { Transporter } from "nodemailer"; +import type { IPluginOptions } from "nodemailer-mjml"; import type { Options } from "nodemailer/lib/mailer/"; import type { Options as SMTPOptions } from "nodemailer/lib/smtp-transport"; -import type { IPluginOptions } from "nodemailer-mjml"; + +type FastifyMailer = FastifyMailerNamedInstance & Transporter; + +interface FastifyMailerNamedInstance { + [namespace: string]: Transporter; +} interface MailerConfig { - defaults: Partial & { + defaults: { from: { address: string; name: string; }; - }; + } & Partial; /** * Any email sent from the API will be directed to these addresses. */ recipients?: string[]; - templating: IPluginOptions; templateData?: Record; + templating: IPluginOptions; test?: { enabled: boolean; path: string; @@ -26,15 +32,9 @@ interface MailerConfig { type MailerOptions = MailerConfig; -interface FastifyMailerNamedInstance { - [namespace: string]: Transporter; -} - -type FastifyMailer = FastifyMailerNamedInstance & Transporter; - export type { - FastifyMailerNamedInstance, FastifyMailer, + FastifyMailerNamedInstance, MailerConfig, MailerOptions, }; diff --git a/packages/mailer/vite.config.ts b/packages/mailer/vite.config.ts index c006fcba5..caa5274ca 100644 --- a/packages/mailer/vite.config.ts +++ b/packages/mailer/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { dependencies, peerDependencies } from "./package.json"; diff --git a/packages/s3/FEATURES.md b/packages/s3/FEATURES.md new file mode 100644 index 000000000..dc272527d --- /dev/null +++ b/packages/s3/FEATURES.md @@ -0,0 +1,115 @@ + + +# Features: @prefabs.tech/fastify-s3 + +## Plugin Registration + +1. **Main plugin (`default` export)** — Fastify plugin wrapped with `fastify-plugin`. On registration it runs database migrations, conditionally registers `@fastify/multipart` (when `config.rest.enabled` is `true`), and conditionally registers GraphQL upload support (when `config.graphql?.enabled` is `true`). + +2. **Automatic database migration** — On registration the plugin creates (if not exists) the files table using the configured table name (`config.s3.table.name`) or the default name `"files"`. + +3. **Conditional REST multipart registration** — When `config.rest.enabled` is `true`, `@fastify/multipart` is registered with `attachFieldsToBody: "keyValues"`, a shared schema id of `"fileSchema"`, a file-size limit from `config.s3.fileSizeLimitInBytes` (defaults to `Infinity`), and an `onFile` hook that converts every part to a `{ data, encoding, filename, mimetype }` object and attaches it as the field value. + +4. **Conditional GraphQL upload registration** — When `config.graphql?.enabled` is `true`, the internal `graphqlUpload` plugin is registered, passing `maxFileSize` from `config.s3.fileSizeLimitInBytes` (defaults to `Infinity`). + +## Configuration + +5. **`S3Config` interface** — Defines the `s3` key required inside `ApiConfig`: + - `clientConfig: S3ClientConfig` — passed straight to the AWS SDK `S3Client` constructor. + - `bucket: string | Record` — default bucket or named-bucket map. + - `fileSizeLimitInBytes?: number` — optional global file-size cap applied to both REST and GraphQL upload paths. + - `filenameResolutionStrategy?: "overwrite" | "add-suffix" | "error"` — global default strategy when a key collision is detected in S3. + - `table?: { name?: string }` — overrides the default `"files"` table name. + +6. **Module augmentation of `@prefabs.tech/fastify-config`** — Adds `s3: S3Config` to the `ApiConfig` interface so the config is accessible via `fastify.config.s3` throughout the application. + +## Sub-plugins (independently exportable) + +7. **`ajvFilePlugin`** — AJV keyword plugin that registers the `isFile` custom keyword. Schemas using `isFile: true` validate that the value is a multipart file object (`{ data, filename, mimetype }`). For array schemas it validates every element. During compile the keyword also rewrites the parent schema (`type: "string"`, `format: "binary"`) so OpenAPI tooling renders a proper file-upload schema. + +8. **`multipartParserPlugin`** — Fastify plugin that registers a catch-all `"*"` content-type parser. For multipart requests it routes GraphQL-multipart requests (matching the configured GraphQL path) by setting `req.graphqlFileUploadMultipart = true`, while all other multipart requests are parsed immediately with Busboy into `{ fields..., files... }` and stored on `req.body`. Non-multipart content types fall through unchanged. Augments `FastifyRequest` with the optional `graphqlFileUploadMultipart?: boolean` property. + +## `S3Client` Utility Class + +9. **`S3Client` class** — Thin class wrapper around `@aws-sdk/client-s3`. Constructed with an `S3ClientConfig`. Exposes a mutable `bucket` property so a single instance can be reused across different buckets. + +10. **`S3Client.upload(fileStream, key, mimetype)`** — Uploads a `Buffer` or `ReadStream` to the configured bucket using `@aws-sdk/lib-storage` `Upload` (supports multipart uploads). Returns `AbortMultipartUploadCommandOutput | CompleteMultipartUploadCommandOutput`. + +11. **`S3Client.get(filePath)`** — Downloads an object and returns `{ Body: Buffer, ContentType: string | undefined }`. The response stream is consumed internally and converted to a `Buffer` via `convertStreamToBuffer`. + +12. **`S3Client.delete(filePath)`** — Sends a `DeleteObjectCommand` and returns `DeleteObjectCommandOutput`. + +13. **`S3Client.generatePresignedUrl(filePath, originalFileName, signedUrlExpiresInSecond?)`** — Generates a `GetObject` presigned URL that forces `Content-Disposition: attachment; filename=""`. Default expiry is `3600` seconds. + +14. **`S3Client.getObjects(baseName)`** — Lists all objects in the bucket whose key starts with the given prefix. Returns `ListObjectsCommandOutput`. + +15. **`S3Client.isFileExists(key)`** — Uses `HeadObjectCommand` to check existence. Returns `true` if the object exists, `false` on a `NotFound` error, and re-throws all other errors. + +## `FileService` (Database + S3 Coordinator) + +16. **`FileService` class** — Extends `BaseService` from `@prefabs.tech/fastify-slonik`. Coordinates S3 operations with database persistence using the `files` table (or the configured table name). + +17. **`FileService.upload(data: FilePayload)`** — Full upload pipeline: + - Determines the target bucket via `getPreferredBucket` (respects `bucketChoice: "optionsBucket" | "fileFieldsBucket"` or falls back to whichever bucket is set). + - Checks if the key already exists in S3 (`isFileExists`). + - Applies `filenameResolutionStrategy`: `"error"` throws `FILE_ALREADY_EXISTS_IN_S3_ERROR`; `"add-suffix"` lists existing objects with the same base name and appends the next numeric suffix (e.g. `report-2.pdf`); `"overwrite"` proceeds without modification. + - Falls back to a UUID-based filename when no name is provided. + - Persists the record to the database via `BaseService.create`. + +18. **`FileService.download(id, options?)`** — Looks up the file record by ID (throws `FILE_NOT_FOUND_ERROR` if missing), retrieves the S3 object, and returns the file record merged with `{ fileStream: Buffer, mimeType: string }`. + +19. **`FileService.deleteFile(fileId, options?)`** — Looks up the file record (throws `FILE_NOT_FOUND_ERROR` if missing), deletes the database record, then deletes the S3 object. + +20. **`FileService.presignedUrl(id, options: PresignedUrlOptions)`** — Looks up the file record (throws `FILE_NOT_FOUND_ERROR` if missing) and returns the record merged with `{ url: string }` — the presigned download URL. + +21. **`FileService.key` (computed property)** — Builds the S3 object key as `/`, normalising the trailing slash on `path`. + +22. **`FileService.filename` (computed property with UUID fallback)** — Returns the configured filename (adding the extension if missing), or a `uuid-v4.ext` name when no filename is set. + +## `FileSqlFactory` + +23. **`FileSqlFactory`** — Extends `DefaultSqlFactory` from `@prefabs.tech/fastify-slonik`. Overrides the `table` getter to return `config.s3.table.name` when set, falling back to the static default `"files"`. + +## Utility Functions + +24. **`convertStreamToBuffer(stream)`** — Internal utility used by `S3Client.get` to consume a `Readable` stream and resolve a single concatenated `Buffer` (not exported from the package root). + +25. **`getPreferredBucket(optionsBucket?, fileFieldsBucket?, bucketChoice?)`** — Determines which bucket to use. With explicit `bucketChoice` the named bucket wins; without it, `fileFieldsBucket` takes precedence over `optionsBucket` when both are present. + +26. **`getFilenameWithSuffix(listObjects, baseFilename, fileExtension)`** — Scans existing S3 object keys matching `-.`, finds the maximum `N`, and returns `-.`. + +27. **`getBaseName(filename)`** — Strips the last extension from a filename string. + +28. **`getFileExtension(filename)`** — Extracts the extension (without dot) from a filename string. Returns `""` for extensionless filenames. + +## Database Schema (Auto-migrated) + +29. **`createFilesTableQuery(config)`** — Returns a `CREATE TABLE IF NOT EXISTS` SQL query for the files table with columns: `id`, `original_file_name`, `bucket`, `description`, `key`, `uploaded_by_id`, `uploaded_at`, `download_count` (default `0`), `last_downloaded_at`, `created_at`, `updated_at`. Table name comes from `config.s3.table.name` or defaults to `"files"`. + +## Error Codes + +30. **`ERROR_CODES.FILE_NOT_FOUND`** (`"FILE_NOT_FOUND_ERROR"`) — Thrown by `FileService.download`, `presignedUrl`, and `deleteFile` when the requested file ID is not found in the database. + +31. **`ERROR_CODES.FILE_ALREADY_EXISTS_IN_S3`** (`"FILE_ALREADY_EXISTS_IN_S3_ERROR"`) — Thrown by `FileService.upload` when a key collision is detected and `filenameResolutionStrategy` is `"error"`. + +## Type Exports + +32. **`S3Config`** — Plugin configuration shape (see Feature 5). + +33. **`FilePayload`** — Input type for `FileService.upload`, containing `{ file: { fileContent: Multipart, fileFields: FileCreateInput }, options?: FilePayloadOptions }`. + +34. **`FilePayloadOptions`** — Upload options: `bucket?`, `bucketChoice?`, `filenameResolutionStrategy?`, `path?`. + +35. **`Multipart`** — Normalised multipart file object: `{ data: Buffer | ReadStream, encoding?, filename, limit?, mimetype }`. + +36. **`FilenameResolutionStrategy`** — Union type `"overwrite" | "add-suffix" | "error"`. + +37. **`BucketChoice`** — Union type `"optionsBucket" | "fileFieldsBucket"`. + +38. **`File`** — Database model for a file record. + +39. **`FileCreateInput`** / **`FileUpdateInput`** — Input types for creating and updating file records. + +40. **`GraphQLFileUpload`** / **`GraphQLUpload`** — Re-exported from `graphql-upload-minimal` for consumers using GraphQL file uploads. + +41. **`S3ClientConfig`** — Re-exported from `@aws-sdk/client-s3` for consumers constructing raw S3 client configurations. diff --git a/packages/s3/GUIDE.md b/packages/s3/GUIDE.md new file mode 100644 index 000000000..876acacbc --- /dev/null +++ b/packages/s3/GUIDE.md @@ -0,0 +1,855 @@ +# @prefabs.tech/fastify-s3 — Developer Guide + +## Installation + +### For package consumers (npm + pnpm) + +```bash +# npm +npm install @prefabs.tech/fastify-s3 + +# pnpm +pnpm add @prefabs.tech/fastify-s3 +``` + +Peer dependencies that must be installed alongside this package: + +```bash +pnpm add fastify fastify-plugin slonik zod \ + @prefabs.tech/fastify-config \ + @prefabs.tech/fastify-error-handler \ + @prefabs.tech/fastify-graphql \ + @prefabs.tech/fastify-slonik +``` + +### For monorepo development (pnpm install / test / build) + +```bash +# From the repo root +pnpm install + +# Run tests for this package only +pnpm --filter @prefabs.tech/fastify-s3 test + +# Build +pnpm --filter @prefabs.tech/fastify-s3 build +``` + +--- + +## Setup + +Register the plugin once. All later examples assume this setup is already in place. + +```typescript +import configPlugin from "@prefabs.tech/fastify-config"; +import Fastify from "fastify"; +import s3Plugin, { ajvFilePlugin } from "@prefabs.tech/fastify-s3"; +import slonikPlugin from "@prefabs.tech/fastify-slonik"; + +// Extend ApiConfig so TypeScript knows about config.s3 +// (the module augmentation in index.ts handles this automatically when you import the package) +import "@prefabs.tech/fastify-s3"; + +const fastify = Fastify({ + ajv: { + plugins: [ajvFilePlugin], // enable isFile keyword in route schemas + }, +}); + +// Register config plugin first (fastify-config peer dep) +await fastify.register(configPlugin, { + s3: { + clientConfig: { + region: "us-east-1", + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + }, + }, + bucket: "my-app-uploads", + fileSizeLimitInBytes: 10 * 1024 * 1024, // 10 MB + filenameResolutionStrategy: "add-suffix", + table: { name: "files" }, // optional, "files" is the default + }, + rest: { enabled: true }, + graphql: { enabled: false }, +}); + +// Register slonik plugin (peer dep) before s3 plugin +await fastify.register(slonikPlugin); + +// Register the S3 plugin +await fastify.register(s3Plugin); + +await fastify.listen({ port: 3000 }); +``` + +--- + +## Base Libraries + +### `@aws-sdk/client-s3` — Modified + +Official docs: https://www.npmjs.com/package/@aws-sdk/client-s3 + +The `S3Client` class in this package wraps the AWS SDK `S3Client`. The raw `S3ClientConfig` type is re-exported for consumers that need it. Commands (`GetObjectCommand`, `DeleteObjectCommand`, `ListObjectsCommand`, `HeadObjectCommand`) are used internally and are not exposed directly. We add: + +- A mutable `bucket` property on the class so one instance can target multiple buckets. +- `get`, `upload`, `delete`, `getObjects`, `isFileExists`, and `generatePresignedUrl` convenience methods (see the S3Client section below). + +### `@aws-sdk/lib-storage` — Full Passthrough + +Official docs: https://www.npmjs.com/package/@aws-sdk/lib-storage + +`Upload` is used internally inside `S3Client.upload` to support multipart S3 uploads. No API surface from this library is exposed to consumers. + +### `@aws-sdk/s3-request-presigner` — Full Passthrough + +Official docs: https://www.npmjs.com/package/@aws-sdk/s3-request-presigner + +`getSignedUrl` is used internally inside `S3Client.generatePresignedUrl`. Not exposed directly. + +### `@fastify/multipart` — Modified + +Official docs: https://www.npmjs.com/package/@fastify/multipart + +Registered automatically (when `config.rest.enabled` is `true`) with a fixed configuration: + +- `attachFieldsToBody: "keyValues"` — non-file fields are attached directly to `req.body`. +- `sharedSchemaId: "fileSchema"` — registers the file JSON schema under this id. +- `limits.fileSize` set from `config.s3.fileSizeLimitInBytes`. +- An `onFile` hook converts each multipart file part into a `Multipart` object (`{ data: Buffer, encoding, filename, mimetype }`) before route handlers run. + +Consumers cannot change these options through this plugin; register `@fastify/multipart` manually if custom options are needed. + +### `graphql-upload-minimal` — Partial Passthrough + +Official docs: https://www.npmjs.com/package/graphql-upload-minimal + +`processRequest` and `UploadOptions` are used in the internal `graphqlUpload` plugin. The `FileUpload` and `Upload` types are re-exported as `GraphQLFileUpload` and `GraphQLUpload`. We add a `preValidation` hook that calls `processRequest` only when `req.graphqlFileUploadMultipart` is `true` (set by `multipartParserPlugin`). + +### `busboy` — Full Passthrough + +Official docs: https://www.npmjs.com/package/busboy + +Used internally in `processMultipartFormData` (called by `multipartParserPlugin`) to parse multipart bodies outside the GraphQL path. Not exposed to consumers. + +### `uuid` — Full Passthrough + +Official docs: https://www.npmjs.com/package/uuid + +`uuidv4()` is used inside `FileService` to generate fallback filenames. Not exposed to consumers. + +--- + +## Features + +### 1 — Main plugin registration + +Import the default export and register it with Fastify. The plugin uses `fastify-plugin` so decorators are not scoped. + +```typescript +import s3Plugin from "@prefabs.tech/fastify-s3"; + +await fastify.register(s3Plugin); +// Runs DB migration, registers multipart (if REST enabled), +// registers GraphQL upload hook (if GraphQL enabled). +``` + +### 2 — Automatic database migration + +On every registration the plugin issues `CREATE TABLE IF NOT EXISTS` for the files table. No manual migration command is needed. The table name comes from `config.s3.table.name` (default `"files"`). + +### 3 — Conditional REST multipart + +When `config.rest.enabled` is `true`, `@fastify/multipart` is registered automatically. File parts are available on `req.body` as `Multipart` objects. + +```typescript +// config +rest: { + enabled: true; +} + +// Route — file is ready as a Multipart object on req.body +fastify.post( + "/upload", + { + schema: { + body: { + type: "object", + properties: { + file: { isFile: true }, // single file + attachments: { + // array of files + type: "array", + items: { isFile: true }, + }, + }, + required: ["file"], + }, + }, + }, + async (request) => { + const { file } = request.body as { file: Multipart }; + // file.data is a Buffer, file.filename, file.mimetype are strings + }, +); +``` + +### 4 — Conditional GraphQL upload registration + +When `config.graphql?.enabled` is `true`, the plugin registers a `preValidation` hook that calls `processRequest` from `graphql-upload-minimal` for multipart GraphQL requests. You must also register `multipartParserPlugin` to set the `graphqlFileUploadMultipart` flag. + +```typescript +// config +graphql: { enabled: true, path: "/graphql" } + +// Also register multipartParserPlugin (see Feature 8) +await fastify.register(multipartParserPlugin); +await fastify.register(s3Plugin); +``` + +### 5 — `S3Config` configuration shape + +Provide `s3` inside the config plugin options: + +```typescript +import type { S3Config } from "@prefabs.tech/fastify-s3"; + +const s3Config: S3Config = { + clientConfig: { + region: "eu-west-1", + credentials: { + accessKeyId: "AKIA...", + secretAccessKey: "...", + }, + }, + bucket: "primary-bucket", // or { avatars: "avatars-bucket", docs: "docs-bucket" } + fileSizeLimitInBytes: 5 * 1024 * 1024, // 5 MB + filenameResolutionStrategy: "add-suffix", + table: { name: "uploaded_files" }, +}; +``` + +### 6 — Module augmentation of `@prefabs.tech/fastify-config` + +Importing `@prefabs.tech/fastify-s3` automatically extends `ApiConfig` with the `s3` key. No manual interface merging is required. + +```typescript +import "@prefabs.tech/fastify-s3"; // side-effect: augments ApiConfig + +// Now TypeScript knows about fastify.config.s3 +const bucket = fastify.config.s3.bucket; +``` + +### 7 — `ajvFilePlugin` — custom `isFile` AJV keyword + +Pass the plugin to Fastify's AJV options to enable the `isFile` keyword in route body schemas. + +```typescript +import Fastify from "fastify"; +import { ajvFilePlugin } from "@prefabs.tech/fastify-s3"; + +const fastify = Fastify({ + ajv: { plugins: [ajvFilePlugin] }, +}); + +// Use in a route schema: +fastify.post("/upload", { + schema: { + body: { + type: "object", + properties: { + document: { isFile: true }, + images: { type: "array", items: { isFile: true } }, + }, + }, + }, + handler: async (request) => { + /* ... */ + }, +}); +``` + +At runtime, `isFile: true` validates that the value has `data`, `filename`, and `mimetype` properties. It also rewrites the schema to `{ type: "string", format: "binary" }` so Swagger UI renders a file picker. + +### 8 — `multipartParserPlugin` — catch-all content-type parser + +Register this plugin when your application handles both GraphQL file uploads and REST file uploads, or whenever you need busboy-based multipart parsing outside of `@fastify/multipart`. + +```typescript +import { multipartParserPlugin } from "@prefabs.tech/fastify-s3"; + +await fastify.register(multipartParserPlugin); +``` + +For multipart requests to the GraphQL path, it sets `req.graphqlFileUploadMultipart = true` (the `graphqlUpload` preValidation hook checks this flag). For all other multipart requests it parses the body via Busboy and attaches fields and files to `req.body`. + +### 9 — `S3Client` class + +Use `S3Client` directly when you need raw S3 access outside the `FileService` abstraction. + +```typescript +import { S3Client } from "@prefabs.tech/fastify-s3"; +import type { S3ClientConfig } from "@prefabs.tech/fastify-s3"; + +const clientConfig: S3ClientConfig = { + region: "us-east-1", + credentials: { accessKeyId: "...", secretAccessKey: "..." }, +}; + +const client = new S3Client(clientConfig); +client.bucket = "my-bucket"; +``` + +### 10 — `S3Client.upload` + +```typescript +import { ReadStream, createReadStream } from "node:fs"; + +// Upload from a Buffer +const buffer = Buffer.from("hello world"); +await client.upload(buffer, "path/to/hello.txt", "text/plain"); + +// Upload from a ReadStream +const stream: ReadStream = createReadStream("/tmp/photo.jpg"); +await client.upload(stream, "images/photo.jpg", "image/jpeg"); +``` + +### 11 — `S3Client.get` + +```typescript +const { Body, ContentType } = await client.get("path/to/hello.txt"); +// Body is a Buffer, ContentType is e.g. "text/plain" +``` + +### 12 — `S3Client.delete` + +```typescript +const output = await client.delete("path/to/hello.txt"); +// output is DeleteObjectCommandOutput +``` + +### 13 — `S3Client.generatePresignedUrl` + +```typescript +// Default expiry: 3600 seconds +const url = await client.generatePresignedUrl( + "uploads/report.pdf", + "Q3 Report.pdf", +); + +// Custom expiry: 15 minutes +const shortUrl = await client.generatePresignedUrl( + "uploads/report.pdf", + "Q3 Report.pdf", + 900, +); +``` + +The URL includes `Content-Disposition: attachment; filename=""` so browsers trigger a download. + +### 14 — `S3Client.getObjects` + +```typescript +const result = await client.getObjects("uploads/2024/"); +// result.Contents lists all keys with that prefix +``` + +### 15 — `S3Client.isFileExists` + +```typescript +const exists = await client.isFileExists("uploads/photo.jpg"); +if (exists) { + console.log("File is already in S3"); +} +``` + +Returns `false` for `NotFound` errors and re-throws any other error. + +### 16 — `FileService` class + +`FileService` extends `BaseService` and inherits its full CRUD interface. Construct it with the Fastify `config` and a Slonik `database` connection. + +```typescript +import { FileService } from "@prefabs.tech/fastify-s3"; + +// Typically constructed inside a route or service layer: +const service = new FileService({ + config: fastify.config, + database: fastify.slonik, +}); +``` + +### 17 — `FileService.upload` + +```typescript +import type { FilePayload } from "@prefabs.tech/fastify-s3"; + +const payload: FilePayload = { + file: { + fileContent: { + data: Buffer.from("..."), + filename: "report.pdf", + mimetype: "application/pdf", + }, + fileFields: { + bucket: "docs-bucket", + uploadedAt: Date.now(), + uploadedById: "user-123", + }, + }, + options: { + path: "reports/2024", + filenameResolutionStrategy: "add-suffix", // overrides config-level default + }, +}; + +const file = await service.upload(payload); +// file contains the persisted DB record including id, key, originalFileName, etc. +``` + +### 18 — `FileService.download` + +```typescript +const result = await service.download(42); +// result.fileStream is a Buffer of the file's raw bytes +// result.mimeType is the Content-Type from S3 +// ...plus all columns from the files table + +// With an explicit bucket override: +const result2 = await service.download(42, { bucket: "archive-bucket" }); +``` + +### 19 — `FileService.deleteFile` + +```typescript +await service.deleteFile(42); +// Removes the DB record first, then deletes the S3 object. + +// Override bucket if stored metadata bucket is stale: +await service.deleteFile(42, { bucket: "old-bucket" }); +``` + +### 20 — `FileService.presignedUrl` + +```typescript +import type { PresignedUrlOptions } from "@prefabs.tech/fastify-s3"; + +const options: PresignedUrlOptions = { + signedUrlExpiresInSecond: 1800, // 30 minutes; default 3600 +}; + +const result = await service.presignedUrl(42, options); +console.log(result.url); // https://s3.amazonaws.com/... +``` + +### 21 — `FileService.key` computed property + +The S3 object key is built from `/`. A trailing `/` is added to `path` automatically if absent. + +```typescript +service.path = "images/avatars"; +service.filename = "user-99.jpg"; +console.log(service.key); // "images/avatars/user-99.jpg" + +service.path = "images/avatars/"; // already has trailing slash +console.log(service.key); // "images/avatars/user-99.jpg" +``` + +### 22 — `FileService.filename` UUID fallback + +When `filename` has not been set on the service, the getter generates a UUID-based name: + +```typescript +service.fileExtension = "png"; +// service.filename not set +console.log(service.filename); // e.g. "550e8400-e29b-41d4-a716-446655440000.png" + +// Explicit filename without extension — extension appended automatically: +service.filename = "avatar"; +service.fileExtension = "png"; +console.log(service.filename); // "avatar.png" +``` + +### 23 — `FileSqlFactory` and configurable table name + +`FileSqlFactory` overrides the `table` getter from `DefaultSqlFactory` to respect `config.s3.table.name`. You generally do not instantiate this directly — `FileService` uses it internally. + +```typescript +// config.s3.table.name = "uploaded_files" +// All FileService queries will target "uploaded_files" instead of "files" +``` + +### 24 — `convertStreamToBuffer` + +Internal utility used by `S3Client.get` to normalize a stream response into a `Buffer`: + +```typescript +const { Body, ContentType } = await client.get("uploads/report.pdf"); + +// Body is already converted to Buffer by the internal helper. +console.log(Body instanceof Buffer); // true +console.log(ContentType); +``` + +Note: `convertStreamToBuffer` is intentionally not part of the package's public export surface. + +### 25 — `getPreferredBucket` utility + +Controls which bucket wins when both `options.bucket` and `fileFields.bucket` are provided: + +```typescript +// Explicit bucketChoice +options: { + bucket: "archive", + bucketChoice: "optionsBucket", // "archive" wins +} + +options: { + bucketChoice: "fileFieldsBucket", // fileFields.bucket wins +} + +// No bucketChoice — fileFields.bucket takes precedence when both present +options: { + bucket: "archive", + // no bucketChoice +} +// fileFields.bucket wins if set +``` + +### 26 — `getFilenameWithSuffix` — add-suffix strategy detail + +When `filenameResolutionStrategy` is `"add-suffix"` and a collision is found, the service lists existing S3 objects with the same base name and picks the next numeric suffix: + +``` +Existing keys: report.pdf, report-1.pdf, report-2.pdf +→ New key: report-3.pdf +``` + +### 27 / 28 — `getBaseName` and `getFileExtension` + +Internally used to split filenames before applying suffix logic. Not part of the public exports. + +### 29 — `createFilesTableQuery` migration query + +Exported for consumers who manage their own migration tooling: + +```typescript +import { createFilesTableQuery } from "@prefabs.tech/fastify-s3"; + +const query = createFilesTableQuery(config); +await database.connect(async (conn) => conn.query(query)); +``` + +### 30 — `ERROR_CODES.FILE_NOT_FOUND` + +```typescript +import { ERROR_CODES } from "@prefabs.tech/fastify-s3"; + +try { + await service.download(999); +} catch (err: unknown) { + if (err instanceof CustomError && err.code === ERROR_CODES.FILE_NOT_FOUND) { + reply.status(404).send({ error: "File not found" }); + } +} +``` + +### 31 — `ERROR_CODES.FILE_ALREADY_EXISTS_IN_S3` + +```typescript +import { ERROR_CODES } from "@prefabs.tech/fastify-s3"; + +// config.s3.filenameResolutionStrategy = "error" +try { + await service.upload(payload); +} catch (err: unknown) { + if ( + err instanceof CustomError && + err.code === ERROR_CODES.FILE_ALREADY_EXISTS_IN_S3 + ) { + reply.status(409).send({ error: "A file with that name already exists" }); + } +} +``` + +### 32–41 — Type exports + +All types are importable from the package root: + +```typescript +import type { + S3Config, + FilePayload, + FilePayloadOptions, + Multipart, + FilenameResolutionStrategy, + BucketChoice, + File, + FileCreateInput, + FileUpdateInput, + GraphQLFileUpload, + GraphQLUpload, + S3ClientConfig, +} from "@prefabs.tech/fastify-s3"; +``` + +--- + +## Use Cases + +### Use Case 1 — REST file upload with route validation + +Accept a single file via a REST endpoint and store it in S3, persisting metadata to the database. + +```typescript +import Fastify from "fastify"; +import s3Plugin, { ajvFilePlugin, FileService } from "@prefabs.tech/fastify-s3"; +import type { Multipart, FilePayload } from "@prefabs.tech/fastify-s3"; + +const fastify = Fastify({ ajv: { plugins: [ajvFilePlugin] } }); +// ... register config, slonik, then s3Plugin ... + +fastify.post<{ + Body: { document: Multipart; description: string }; +}>( + "/documents", + { + schema: { + body: { + type: "object", + properties: { + document: { isFile: true }, + description: { type: "string" }, + }, + required: ["document"], + }, + }, + }, + async (request, reply) => { + const { document, description } = request.body; + + const service = new FileService({ + config: fastify.config, + database: fastify.slonik, + }); + + const payload: FilePayload = { + file: { + fileContent: document, + fileFields: { + description, + uploadedAt: Date.now(), + uploadedById: request.user?.id, + }, + }, + options: { + path: "documents", + }, + }; + + const file = await service.upload(payload); + return reply.status(201).send(file); + }, +); +``` + +### Use Case 2 — Multiple bucket routing per upload type + +Use a named-bucket map and `bucketChoice` to route different file types to different buckets. + +```typescript +// Config +s3: { + clientConfig: { region: "us-east-1", credentials: { ... } }, + bucket: { + avatars: "my-app-avatars", + documents: "my-app-docs", + }, +} + +// Upload handler — avatar goes to avatars bucket +const payload: FilePayload = { + file: { + fileContent: avatarFile, + fileFields: { + bucket: fastify.config.s3.bucket["avatars"], + uploadedAt: Date.now(), + }, + }, + options: { + bucketChoice: "fileFieldsBucket", + path: `users/${userId}/avatar`, + }, +}; +const record = await service.upload(payload); +``` + +### Use Case 3 — Generating a time-limited download URL + +Return a presigned URL so the client can download a file directly from S3 without proxying through your server. + +```typescript +fastify.get<{ Params: { id: string } }>( + "/files/:id/download-url", + async (request, reply) => { + const service = new FileService({ + config: fastify.config, + database: fastify.slonik, + }); + + const { url } = await service.presignedUrl(Number(request.params.id), { + signedUrlExpiresInSecond: 300, // 5 minutes + }); + + return reply.send({ url }); + }, +); +``` + +### Use Case 4 — Streaming a file back through the server + +Retrieve the raw bytes from S3 and send them in the response. + +```typescript +fastify.get<{ Params: { id: string } }>( + "/files/:id", + async (request, reply) => { + const service = new FileService({ + config: fastify.config, + database: fastify.slonik, + }); + + const { fileStream, mimeType, originalFileName } = await service.download( + Number(request.params.id), + ); + + return reply + .header("Content-Type", mimeType ?? "application/octet-stream") + .header( + "Content-Disposition", + `attachment; filename="${originalFileName}"`, + ) + .send(fileStream); + }, +); +``` + +### Use Case 5 — Deleting a file + +Remove both the S3 object and the database record in one call. + +```typescript +fastify.delete<{ Params: { id: string } }>( + "/files/:id", + async (request, reply) => { + const service = new FileService({ + config: fastify.config, + database: fastify.slonik, + }); + + await service.deleteFile(Number(request.params.id)); + return reply.status(204).send(); + }, +); +``` + +### Use Case 6 — GraphQL file upload + +Enable GraphQL multipart support and handle file uploads in a GraphQL mutation. + +```typescript +// Registration (order matters) +await fastify.register(configPlugin, { + s3: { clientConfig: { ... }, bucket: "uploads", fileSizeLimitInBytes: 10_000_000 }, + rest: { enabled: false }, + graphql: { enabled: true, path: "/graphql" }, +}); +await fastify.register(slonikPlugin); +await fastify.register(multipartParserPlugin); // must come before s3Plugin +await fastify.register(s3Plugin); +await fastify.register(graphqlPlugin); // your Mercurius/graphql plugin + +// GraphQL resolver +const resolvers = { + Mutation: { + uploadFile: async (_: unknown, args: { file: GraphQLUpload }) => { + const { createReadStream, filename, mimetype } = await args.file; + + const service = new FileService({ config: fastify.config, database: fastify.slonik }); + + return service.upload({ + file: { + fileContent: { + data: createReadStream(), + filename, + mimetype, + }, + fileFields: { uploadedAt: Date.now() }, + }, + }); + }, + }, +}; +``` + +### Use Case 7 — Collision-safe uploads with automatic suffix + +Configure `filenameResolutionStrategy: "add-suffix"` globally and upload files whose names may collide. + +```typescript +// Config +s3: { + clientConfig: { ... }, + bucket: "reports", + filenameResolutionStrategy: "add-suffix", +} + +// First upload → stored as "annual-report.pdf" +// Second upload of same name → stored as "annual-report-1.pdf" +// Third → "annual-report-2.pdf", etc. +const service = new FileService({ config: fastify.config, database: fastify.slonik }); + +await service.upload({ + file: { + fileContent: { data: pdfBuffer, filename: "annual-report.pdf", mimetype: "application/pdf" }, + fileFields: { uploadedAt: Date.now() }, + }, +}); +``` + +### Use Case 8 — Using `S3Client` directly + +Bypass `FileService` for ad-hoc S3 operations (e.g. listing objects, checking existence) without database involvement. + +```typescript +import { S3Client } from "@prefabs.tech/fastify-s3"; + +const client = new S3Client(fastify.config.s3.clientConfig); +client.bucket = "my-bucket"; + +// Check before uploading +const exists = await client.isFileExists("exports/data.csv"); +if (!exists) { + await client.upload(csvBuffer, "exports/data.csv", "text/csv"); +} + +// List all exports +const { Contents } = await client.getObjects("exports/"); +const keys = Contents?.map((obj) => obj.Key) ?? []; +``` + +### Use Case 9 — Custom migration integration + +Run the migration query inside your own migration pipeline instead of relying on the automatic plugin startup migration. + +```typescript +import { createFilesTableQuery } from "@prefabs.tech/fastify-s3"; + +// Inside your custom migration runner: +await database.connect(async (connection) => { + await connection.query(createFilesTableQuery(config)); +}); +``` diff --git a/packages/s3/README.md b/packages/s3/README.md index 2b0994da0..052dc6d5b 100644 --- a/packages/s3/README.md +++ b/packages/s3/README.md @@ -2,23 +2,44 @@ A [Fastify](https://github.com/fastify/fastify) plugin that provides an easy integration of S3 in a fastify API. +## Why this plugin? + +Handling file uploads in a full-stack context requires substantially more effort than simply pushing byte streams to an S3 bucket via the AWS SDK. You must parse multipart requests, handle potential filename collisions securely, stream data to S3, and immediately synchronize metadata flags to your database. We created this plugin to: + +- **Automate the Full Upload Lifecycle**: From intercepting `multipart/form-data` chunks (via internal parsers), writing to S3, and saving strict structured metadata natively into our `@prefabs.tech/fastify-slonik` powered databases—this plugin handles the entire flow. +- **Standardize Duplication Strategies**: It provides out-of-the-box mechanisms (`error`, `add-suffix`, `override`) to elegantly handle duplicate filenames with zero effort. +- **Bridge REST & GraphQL**: The plugin provides specialized parsers (`ajvFilePlugin` and `multipartParserPlugin`) ensuring that file uploads are supported natively and documented correctly via Swagger (for REST APIs) and GraphQL simultaneously. + +### Design Decisions: Why not @aws-sdk/client-s3 and @fastify/multipart directly? + +- **Too Much Boilerplate**: While those granular tools are fantastic, manually aggregating them to handle incoming parsed streams, S3 buffering, database synchronization, and Swagger schema injection per-route results in massive duplication of boilerplate code across microservices. +- **Ecosystem Homogenization**: This plugin strictly binds the AWS SDK into our ecosystem's configuration (`fastify-config`) and database architecture (`fastify-slonik`), affording you a unified `FileService` that is ready to execute uploads and metadata queries perfectly right after registration. + ## Requirements -* [@prefabs.tech/fastify-config](../config/) -* [@prefabs.tech/fastify-slonik](../slonik/) +Peer dependencies (install compatible versions — see [package.json](./package.json)): + +- [@prefabs.tech/fastify-config](../config/) +- [@prefabs.tech/fastify-error-handler](../error-handler/) +- [@prefabs.tech/fastify-graphql](../graphql/) +- [@prefabs.tech/fastify-slonik](../slonik/) +- [`fastify`](https://www.npmjs.com/package/fastify) +- [`fastify-plugin`](https://www.npmjs.com/package/fastify-plugin) +- [`slonik`](https://www.npmjs.com/package/slonik) +- [`zod`](https://www.npmjs.com/package/zod) ## Installation Install with npm: ```bash -npm install @prefabs.tech/fastify-config @prefabs.tech/fastify-slonik @prefabs.tech/fastify-s3 +npm install @prefabs.tech/fastify-config @prefabs.tech/fastify-error-handler @prefabs.tech/fastify-graphql @prefabs.tech/fastify-slonik @prefabs.tech/fastify-s3 fastify fastify-plugin slonik zod ``` Install with pnpm: ```bash -pnpm add --filter "@scope/project" @prefabs.tech/fastify-config @prefabs.tech/fastify-slonik @prefabs.tech/fastify-s3 +pnpm add --filter "@scope/project" @prefabs.tech/fastify-config @prefabs.tech/fastify-error-handler @prefabs.tech/fastify-graphql @prefabs.tech/fastify-slonik @prefabs.tech/fastify-s3 fastify fastify-plugin slonik zod ``` ## Usage @@ -27,47 +48,44 @@ pnpm add --filter "@scope/project" @prefabs.tech/fastify-config @prefabs.tech/fa When using AWS S3, you are required to enable the following permissions: -***Required Permission:*** +**_Required Permission:_** - GetObject Permission - GetObjectAttributes Permission - PutObject Permission -***Optional Permissions:*** +**_Optional Permissions:_** - ListBucket Permission - If you choose the `add-suffix` option for FilenameResolutionStrategy when dealing with duplicate files, then you have to enable this permission. - DeleteObject Permission - If you use the `deleteFile` method from the file service, you will need this permission - -***Sample S3 Permission:*** +**_Sample S3 Permission:_** ```json - { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Principal": "*", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::your-bucket" - }, - { - "Effect": "Allow", - "Principal": "*", - "Action": [ - "s3:DeleteObject", - "s3:GetObject", - "s3:GetObjectAttributes", - "s3:PutObject" - ], - "Resource": "arn:aws:s3:::your-bucket/*" - } - ] - } +{ + "Version": "2012-10-17", + "Statement": [ + { + "Action": ["s3:ListBucket"], + "Effect": "Allow", + "Principal": "*", + "Resource": "arn:aws:s3:::your-bucket" + }, + { + "Action": [ + "s3:DeleteObject", + "s3:GetObject", + "s3:GetObjectAttributes", + "s3:PutObject" + ], + "Effect": "Allow", + "Principal": "*", + "Resource": "arn:aws:s3:::your-bucket/*" + } + ] +} ``` ### Register plugin @@ -76,7 +94,9 @@ Register the file fastify-s3 package with your Fastify instance: ```typescript import configPlugin from "@prefabs.tech/fastify-config"; -import s3Plugin, { multipartParserPlugin } from "@prefabs.tech/fastify-s3"; +import errorHandlerPlugin from "@prefabs.tech/fastify-error-handler"; +import graphqlPlugin from "@prefabs.tech/fastify-graphql"; +import s3Plugin from "@prefabs.tech/fastify-s3"; import slonikPlugin from "@prefabs.tech/fastify-slonik"; import Fastify from "fastify"; @@ -91,17 +111,23 @@ const start = async () => { // Register config plugin await fastify.register(configPlugin, { config }); + await fastify.register(errorHandlerPlugin, { + stackTrace: process.env.NODE_ENV === "development", + }); + // Register database plugin await fastify.register(slonikPlugin, config.slonik); - - // Register fastify-s3 plugin + + await fastify.register(graphqlPlugin, config.graphql); + + // Register fastify-s3 plugin (see below for multipartParserPlugin when using GraphQL uploads) await fastify.register(s3Plugin); - + await fastify.listen({ - port: config.port, host: "0.0.0.0", + port: config.port, }); -} +}; start(); ``` @@ -115,18 +141,18 @@ AWS S3 Config ```typescript const config: ApiConfig = { // ... other configurations - + s3: { + bucket: "" | { key: "value" }, // Specify your S3 bucket //... AWS S3 client config clientConfig: { credentials: { - accessKeyId: "accessKey", // Replace with your AWS access key - secretAccessKey: "secretKey", // Replace with your AWS secret key + accessKeyId: "accessKey", // Replace with your AWS access key + secretAccessKey: "secretKey", // Replace with your AWS secret key }, - region: "ap-southeast-1" // Replace with your AWS region + region: "ap-southeast-1", // Replace with your AWS region }, - bucket: "" | { key: "value" }, // Specify your S3 bucket - } + }, }; ``` @@ -146,8 +172,9 @@ Minio Service Config ```typescript const config: ApiConfig = { // ... other configurations - + s3: { + bucket: "yourMinioBucketName", clientConfig: { credentials: { accessKeyId: "yourMinioAccessKey", @@ -155,12 +182,10 @@ const config: ApiConfig = { }, endpoint: "http://your-minio-server-url:port", // Replace with your Minio server URL forcePathStyle: true, // Set to true if your Minio server uses path-style URLs - region: "" // For Minio, you can leave the region empty or specify it based on your setup + region: "", // For Minio, you can leave the region empty or specify it based on your setup }, - bucket: "yourMinioBucketName", - } + }, }; - ``` To add a custom table name: @@ -168,15 +193,14 @@ To add a custom table name: ```typescript const config: ApiConfig = { // ... other configurations - + s3: { //... AWS S3 client config table: { - name: "new-table-name" // You can set a custom table name here (default: "files") - } - } + name: "new-table-name", // You can set a custom table name here (default: "files") + }, + }, }; - ``` To limit the file size while uploading: @@ -184,13 +208,12 @@ To limit the file size while uploading: ```typescript const config: ApiConfig = { // ... other configurations - + s3: { //... AWS S3 client config - fileSizeLimitInBytes: 10485760 - } + fileSizeLimitInBytes: 10485760, + }, }; - ``` To handle duplicate filenames: @@ -201,18 +224,18 @@ To handle duplicate filenames: - `override`: This is the default option and it overrides the file if the file name already exists. ```typescript - fileService.upload({ + fileService.upload({ + // ... other options + options: { // ... other options - options: { - // ... other options - filenameResolutionStrategy: "add-suffix", - }, - }); + filenameResolutionStrategy: "add-suffix", + }, + }); ``` ## Using GraphQL -This package supports integration with [@prefabs.tech/fastify-graphql](../graphql/). +This package supports integration with [@prefabs.tech/fastify-graphql](../graphql/). Register additional `multipartParserPlugin` plugin with the fastify instance as shown below: @@ -230,10 +253,10 @@ const start = async () => { const fastify = Fastify({ logger: config.logger, }); - + // Register config plugin await fastify.register(configPlugin, { config }); - + // Register database plugin await fastify.register(slonikPlugin, config.slonik); @@ -247,8 +270,8 @@ const start = async () => { await fastify.register(s3Plugin); await await.listen({ - port: config.port, host: "0.0.0.0", + port: config.port, }); } @@ -257,8 +280,8 @@ start(); **Note**: Register the `multipartParserPlugin` if you're using GraphQL or both GraphQL and REST, as it's required. Make sure to place the registration of the `multipartParserPlugin` above the `graphqlPlugin`. - ## JSON Schema with Swagger + If you want to use @prefabs.tech/fastify-s3 with @fastify/swagger and @fastify/swagger-ui or @prefabs.tech/swagger you must add a new type called `isFile` and use a custom instance of a validator compiler ```typescript @@ -282,10 +305,10 @@ const start = async () => { plugins: [ajvFilePlugin], }, }); - + // Register config plugin await fastify.register(configPlugin, { config }); - + // Register database plugin await fastify.register(slonikPlugin, config.slonik); @@ -300,15 +323,15 @@ const start = async () => { fastify.post('/upload/file', { schema: { - description: "Upload a file", - tags: ["file"], - consumes: ["multipart/form-data"], body: { - type: "object", properties: { file: { isFile: true }, }, + type: "object", }, + consumes: ["multipart/form-data"], + description: "Upload a file", + tags: ["file"], } }, function (req, reply) { console.log({ body: req.body }) diff --git a/packages/s3/eslint.config.js b/packages/s3/eslint.config.js index 48a1291a4..7369a1f05 100644 --- a/packages/s3/eslint.config.js +++ b/packages/s3/eslint.config.js @@ -1,3 +1,22 @@ import fastifyConfig from "@prefabs.tech/eslint-config/fastify.js"; +import perfectionist from "eslint-plugin-perfectionist"; -export default fastifyConfig; +export default [ + ...fastifyConfig, + { + plugins: { + perfectionist, + }, + rules: { + // Disable conflicting default/import rules + "sort-imports": "off", + "import/order": "off", + + // Enable and spread Perfectionist's recommended rules + ...perfectionist.configs["recommended-alphabetical"].rules, + + // Add any Fastify-specific rule overrides here + "@typescript-eslint/no-explicit-any": "error", + }, + }, +]; diff --git a/packages/s3/package.json b/packages/s3/package.json index 7ef42e238..8e248b095 100644 --- a/packages/s3/package.json +++ b/packages/s3/package.json @@ -19,7 +19,9 @@ "main": "./dist/prefabs-tech-fastify-s3.cjs", "module": "./dist/prefabs-tech-fastify-s3.js", "types": "./dist/types/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", @@ -50,6 +52,7 @@ "@types/node": "24.10.13", "@vitest/coverage-istanbul": "3.2.4", "eslint": "9.39.2", + "eslint-plugin-perfectionist": "5.8.0", "fastify": "5.7.4", "fastify-plugin": "5.1.0", "graphql": "16.12.0", diff --git a/packages/s3/src/__test__/multipartParser.test.ts b/packages/s3/src/__test__/multipartParser.test.ts new file mode 100644 index 000000000..e0dc2ea0b --- /dev/null +++ b/packages/s3/src/__test__/multipartParser.test.ts @@ -0,0 +1,126 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// processMultipartFormData is our code — mock it to avoid real busboy parsing +// in unit tests, and to prevent the double-done side effect in source code. +vi.mock("../utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + processMultipartFormData: vi.fn( + (_req: unknown, _payload: unknown, done: (err: null) => void) => + /* slonik returns null so we allow it here */ /* eslint-disable-next-line unicorn/no-null */ + done(null), + ), + }; +}); + +const buildFastify = (graphqlConfig?: { enabled: boolean; path: string }) => { + const instance = Fastify({ logger: false }); + instance.addHook("onRequest", async (req) => { + (req as unknown as { config: unknown }).config = graphqlConfig + ? { graphql: graphqlConfig } + : {}; + }); + return instance; +}; + +describe("multipartParserPlugin", () => { + let fastify: FastifyInstance; + + afterEach(async () => fastify.close()); + + it("does not return 415 for unknown content types after registration", async () => { + fastify = buildFastify(); + const { default: plugin } = await import("../plugins/multipartParser"); + await fastify.register(plugin); + + fastify.post("/test", async () => ({})); + await fastify.ready(); + + // Without the * catch-all parser, Fastify returns 415 for unrecognised content types. + // With it registered, the request reaches the route handler normally. + const response = await fastify.inject({ + headers: { "content-type": "text/csv" }, + method: "POST", + payload: "a,b,c", + url: "/test", + }); + + expect(response.statusCode).not.toBe(415); + }); + + it("sets graphqlFileUploadMultipart on the request for multipart requests to the graphql path", async () => { + fastify = buildFastify({ enabled: true, path: "/graphql" }); + const { default: plugin } = await import("../plugins/multipartParser"); + await fastify.register(plugin); + + let capturedFlag: boolean | undefined; + fastify.post("/graphql", async (req) => { + capturedFlag = req.graphqlFileUploadMultipart; + return {}; + }); + + await fastify.ready(); + + await fastify.inject({ + headers: { "content-type": "multipart/form-data; boundary=----abc" }, + method: "POST", + payload: "------abc--\r\n", + url: "/graphql", + }); + + expect(capturedFlag).toBe(true); + }); + + it("does not set graphqlFileUploadMultipart for multipart requests outside the graphql path", async () => { + const { processMultipartFormData } = await import("../utils"); + + fastify = buildFastify({ enabled: true, path: "/graphql" }); + const { default: plugin } = await import("../plugins/multipartParser"); + await fastify.register(plugin); + + let capturedFlag: boolean | undefined; + fastify.post("/upload", async (req) => { + capturedFlag = req.graphqlFileUploadMultipart; + return {}; + }); + + await fastify.ready(); + + await fastify.inject({ + headers: { "content-type": "multipart/form-data; boundary=----abc" }, + method: "POST", + payload: "------abc--\r\n", + url: "/upload", + }); + + expect(capturedFlag).toBeUndefined(); + expect(processMultipartFormData).toHaveBeenCalled(); + }); + + it("does not set graphqlFileUploadMultipart when graphql is disabled", async () => { + fastify = buildFastify({ enabled: false, path: "/graphql" }); + const { default: plugin } = await import("../plugins/multipartParser"); + await fastify.register(plugin); + + let capturedFlag: boolean | undefined; + fastify.post("/graphql", async (req) => { + capturedFlag = req.graphqlFileUploadMultipart; + return {}; + }); + + await fastify.ready(); + + await fastify.inject({ + headers: { "content-type": "multipart/form-data; boundary=----abc" }, + method: "POST", + payload: "------abc--\r\n", + url: "/graphql", + }); + + expect(capturedFlag).toBeUndefined(); + }); +}); diff --git a/packages/s3/src/__test__/plugin.test.ts b/packages/s3/src/__test__/plugin.test.ts new file mode 100644 index 000000000..3705b83a1 --- /dev/null +++ b/packages/s3/src/__test__/plugin.test.ts @@ -0,0 +1,242 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ── Mocks (hoisted so vi.mock factories can reference them) ────────────────── + +const { graphqlUploadMock, runMigrationsMock } = vi.hoisted(() => ({ + graphqlUploadMock: vi.fn(async () => {}), + runMigrationsMock: vi.fn().mockResolvedValue(), +})); + +vi.mock("../migrations/runMigrations", () => ({ default: runMigrationsMock })); +vi.mock("../plugins/graphqlUpload", () => ({ default: graphqlUploadMock })); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const buildFastify = (configOverrides: Record = {}) => { + const fastify = Fastify({ logger: false }); + fastify.decorate("config", { + rest: { enabled: true }, + s3: { bucket: "test-bucket", clientConfig: {} }, + ...configOverrides, + }); + fastify.decorate("slonik", {}); + return fastify; +}; + +/** Minimal multipart/form-data body for inject tests (single file field). */ +const buildMultipartFileBody = ( + boundary: string, + fieldName: string, + filename: string, + fileBytes: Buffer, +): Buffer => { + const preamble = Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="${fieldName}"; filename="${filename}"\r\n` + + `Content-Type: application/octet-stream\r\n\r\n`, + "utf8", + ); + const closing = Buffer.from(`\r\n--${boundary}--\r\n`, "utf8"); + return Buffer.concat([preamble, fileBytes, closing]); +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("s3 plugin — initialization", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + afterEach(async () => fastify.close()); + + it("calls runMigrations on startup", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(runMigrationsMock).toHaveBeenCalledOnce(); + }); + + it("passes slonik and config to runMigrations", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(runMigrationsMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ s3: expect.any(Object) }), + ); + }); +}); + +describe("s3 plugin — REST multipart registration", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + afterEach(async () => fastify.close()); + + it("registers @fastify/multipart when config.rest.enabled is true", async () => { + fastify = buildFastify({ rest: { enabled: true } }); + await fastify.register(plugin); + await fastify.ready(); + + // @fastify/multipart registers a multipart/form-data content-type parser + expect(fastify.hasContentTypeParser("multipart/form-data")).toBe(true); + }); + + it("does not register @fastify/multipart when config.rest.enabled is false", async () => { + fastify = buildFastify({ rest: { enabled: false } }); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasContentTypeParser("multipart/form-data")).toBe(false); + }); + + it("rejects multipart uploads larger than fileSizeLimitInBytes with 413", async () => { + const boundary = "----test-boundary-413"; + const limitBytes = 512; + const oversized = Buffer.alloc(limitBytes + 200, 7); + + fastify = buildFastify({ + s3: { + bucket: "test-bucket", + clientConfig: {}, + fileSizeLimitInBytes: limitBytes, + }, + }); + await fastify.register(plugin); + + fastify.post("/upload", async () => ({ ok: true })); + + await fastify.ready(); + + const response = await fastify.inject({ + headers: { + "content-type": `multipart/form-data; boundary=${boundary}`, + }, + method: "POST", + payload: buildMultipartFileBody(boundary, "doc", "big.bin", oversized), + url: "/upload", + }); + + expect(response.statusCode).toBe(413); + }); + + it("attaches normalised file objects to the body for multipart fields within the size limit", async () => { + const boundary = "----test-boundary-ok"; + const fileBytes = Buffer.from("hello-s3"); + + fastify = buildFastify({ + s3: { + bucket: "test-bucket", + clientConfig: {}, + fileSizeLimitInBytes: 50000, + }, + }); + await fastify.register(plugin); + + let body: unknown; + fastify.post("/upload", async (request) => { + body = request.body; + return {}; + }); + + await fastify.ready(); + + const response = await fastify.inject({ + headers: { + "content-type": `multipart/form-data; boundary=${boundary}`, + }, + method: "POST", + payload: buildMultipartFileBody(boundary, "doc", "note.txt", fileBytes), + url: "/upload", + }); + + expect(response.statusCode).toBe(200); + expect(body).toEqual({ + doc: { + data: fileBytes, + encoding: expect.any(String), + filename: "note.txt", + mimetype: "application/octet-stream", + }, + }); + }); +}); + +describe("s3 plugin — GraphQL upload registration", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + afterEach(async () => fastify.close()); + + it("registers the graphql upload plugin when config.graphql.enabled is true", async () => { + fastify = buildFastify({ + graphql: { enabled: true }, + rest: { enabled: false }, + }); + await fastify.register(plugin); + await fastify.ready(); + + expect(graphqlUploadMock).toHaveBeenCalledOnce(); + }); + + it("does not register the graphql upload plugin when config.graphql is undefined", async () => { + fastify = buildFastify({ graphql: undefined, rest: { enabled: false } }); + await fastify.register(plugin); + await fastify.ready(); + + expect(graphqlUploadMock).not.toHaveBeenCalled(); + }); + + it("does not register the graphql upload plugin when config.graphql.enabled is false", async () => { + fastify = buildFastify({ + graphql: { enabled: false }, + rest: { enabled: false }, + }); + await fastify.register(plugin); + await fastify.ready(); + + expect(graphqlUploadMock).not.toHaveBeenCalled(); + }); + + it("passes fileSizeLimitInBytes as maxFileSize to the graphql upload plugin", async () => { + fastify = buildFastify({ + graphql: { enabled: true }, + rest: { enabled: false }, + s3: { fileSizeLimitInBytes: 5_000_000 }, + }); + await fastify.register(plugin); + await fastify.ready(); + + expect(graphqlUploadMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ maxFileSize: 5_000_000 }), + expect.any(Function), + ); + }); + + it("passes Infinity as maxFileSize when fileSizeLimitInBytes is not set", async () => { + fastify = buildFastify({ + graphql: { enabled: true }, + rest: { enabled: false }, + s3: { bucket: "test-bucket", clientConfig: {} }, + }); + await fastify.register(plugin); + await fastify.ready(); + + expect(graphqlUploadMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ maxFileSize: Number.POSITIVE_INFINITY }), + expect.any(Function), + ); + }); +}); diff --git a/packages/s3/src/__test__/s3Client.test.ts b/packages/s3/src/__test__/s3Client.test.ts new file mode 100644 index 000000000..e3c76ad00 --- /dev/null +++ b/packages/s3/src/__test__/s3Client.test.ts @@ -0,0 +1,211 @@ +import { Readable } from "node:stream"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// ── Hoisted mocks ───────────────────────────────────────────────────────────── + +const { mockGetSignedUrl, mockSend, mockUploadCtor, mockUploadDone } = + vi.hoisted(() => ({ + mockGetSignedUrl: vi.fn(), + mockSend: vi.fn(), + mockUploadCtor: vi.fn(), + mockUploadDone: vi.fn(), + })); + +vi.mock("@aws-sdk/client-s3", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + S3Client: vi.fn().mockImplementation(() => ({ send: mockSend })), + }; +}); + +vi.mock("@aws-sdk/s3-request-presigner", () => ({ + getSignedUrl: mockGetSignedUrl, +})); + +vi.mock("@aws-sdk/lib-storage", () => ({ + Upload: vi.fn().mockImplementation((arguments_: unknown) => { + mockUploadCtor(arguments_); + return { done: mockUploadDone }; + }), +})); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("S3Client — isFileExists", async () => { + const { default: S3ClientWrapper } = await import("../utils/s3Client"); + + let client: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + client = new S3ClientWrapper({}); + client.bucket = "test-bucket"; + }); + + it("returns true when the object exists in S3", async () => { + mockSend.mockResolvedValue({ ContentLength: 1024 }); + + const result = await client.isFileExists("avatars/photo.jpg"); + + expect(result).toBe(true); + }); + + it("returns false when S3 throws a NotFound error", async () => { + const notFound = Object.assign(new Error("Not Found"), { + name: "NotFound", + }); + mockSend.mockRejectedValue(notFound); + + const result = await client.isFileExists("avatars/missing.jpg"); + + expect(result).toBe(false); + }); + + it("rethrows errors that are not NotFound", async () => { + const accessDenied = Object.assign(new Error("Access Denied"), { + name: "AccessDenied", + }); + mockSend.mockRejectedValue(accessDenied); + + await expect(client.isFileExists("private/file.txt")).rejects.toThrow( + "Access Denied", + ); + }); +}); + +describe("S3Client — generatePresignedUrl", async () => { + const { default: S3ClientWrapper } = await import("../utils/s3Client"); + + let client: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetSignedUrl.mockResolvedValue("https://presigned.url/file.pdf"); + client = new S3ClientWrapper({}); + client.bucket = "test-bucket"; + }); + + it("uses a default expiry of 3600 seconds when none is provided", async () => { + await client.generatePresignedUrl("reports/q1.pdf", "Q1 Report.pdf"); + + expect(mockGetSignedUrl).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { expiresIn: 3600 }, + ); + }); + + it("uses the provided expiry when specified", async () => { + await client.generatePresignedUrl("reports/q1.pdf", "Q1 Report.pdf", 900); + + expect(mockGetSignedUrl).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { expiresIn: 900 }, + ); + }); + + it("sets ResponseContentDisposition with the original filename", async () => { + const { GetObjectCommand } = await import("@aws-sdk/client-s3"); + + await client.generatePresignedUrl("reports/q1.pdf", "Q1 Report.pdf"); + + const commandArgument = mockGetSignedUrl.mock.calls[0][1]; + expect(commandArgument).toBeInstanceOf(GetObjectCommand); + expect(commandArgument.input.ResponseContentDisposition).toBe( + 'attachment; filename="Q1 Report.pdf"', + ); + }); + + it("returns the signed URL from the presigner", async () => { + const url = await client.generatePresignedUrl("file.pdf", "file.pdf"); + expect(url).toBe("https://presigned.url/file.pdf"); + }); +}); + +describe("S3Client — object operations", async () => { + const { default: S3ClientWrapper } = await import("../utils/s3Client"); + + let client: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + client = new S3ClientWrapper({}); + client.bucket = "test-bucket"; + }); + + it("sends DeleteObjectCommand with bucket and key", async () => { + const { DeleteObjectCommand } = await import("@aws-sdk/client-s3"); + mockSend.mockResolvedValue({ DeleteMarker: true }); + + await client.delete("docs/report.pdf"); + + expect(mockSend).toHaveBeenCalledOnce(); + const commandArgument = mockSend.mock.calls[0][0]; + expect(commandArgument).toBeInstanceOf(DeleteObjectCommand); + expect(commandArgument.input).toEqual({ + Bucket: "test-bucket", + Key: "docs/report.pdf", + }); + }); + + it("sends GetObjectCommand and returns buffered body with content type", async () => { + const { GetObjectCommand } = await import("@aws-sdk/client-s3"); + mockSend.mockResolvedValue({ + Body: Readable.from([Buffer.from("hello"), Buffer.from(" world")]), + ContentType: "text/plain", + }); + + const response = await client.get("docs/report.txt"); + + expect(mockSend).toHaveBeenCalledOnce(); + const commandArgument = mockSend.mock.calls[0][0]; + expect(commandArgument).toBeInstanceOf(GetObjectCommand); + expect(commandArgument.input).toEqual({ + Bucket: "test-bucket", + Key: "docs/report.txt", + }); + expect(response.Body.toString()).toBe("hello world"); + expect(response.ContentType).toBe("text/plain"); + }); + + it("sends ListObjectsCommand with the requested prefix", async () => { + const { ListObjectsCommand } = await import("@aws-sdk/client-s3"); + mockSend.mockResolvedValue({ Contents: [] }); + + await client.getObjects("docs/report"); + + expect(mockSend).toHaveBeenCalledOnce(); + const commandArgument = mockSend.mock.calls[0][0]; + expect(commandArgument).toBeInstanceOf(ListObjectsCommand); + expect(commandArgument.input).toEqual({ + Bucket: "test-bucket", + Prefix: "docs/report", + }); + }); + + it("creates Upload with S3 params and returns done() result", async () => { + mockUploadDone.mockResolvedValue({ ETag: "etag-value" }); + const payload = Buffer.from("content"); + + const result = await client.upload( + payload, + "docs/report.txt", + "text/plain", + ); + + expect(mockUploadCtor).toHaveBeenCalledOnce(); + expect(mockUploadCtor).toHaveBeenCalledWith({ + client: expect.anything(), + params: { + Body: payload, + Bucket: "test-bucket", + ContentType: "text/plain", + Key: "docs/report.txt", + }, + }); + expect(mockUploadDone).toHaveBeenCalledOnce(); + expect(result).toEqual({ ETag: "etag-value" }); + }); +}); diff --git a/packages/s3/src/__test__/service.test.ts b/packages/s3/src/__test__/service.test.ts new file mode 100644 index 000000000..17fd81f93 --- /dev/null +++ b/packages/s3/src/__test__/service.test.ts @@ -0,0 +1,356 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { File } from "../types/file"; + +import { ERROR_CODES } from "../constants"; + +// ── Hoisted mocks ───────────────────────────────────────────────────────────── + +const { mockS3 } = vi.hoisted(() => ({ + mockS3: { + bucket: "" as string, + delete: vi.fn(), + generatePresignedUrl: vi.fn(), + get: vi.fn(), + getObjects: vi.fn(), + isFileExists: vi.fn(), + upload: vi.fn(), + }, +})); + +vi.mock("../utils/s3Client", () => ({ + default: vi.fn(() => mockS3), +})); + +vi.mock("@prefabs.tech/fastify-slonik", () => { + class MockBaseService { + config: ApiConfig; + constructor(config: ApiConfig, ...arguments_: unknown[]) { + void arguments_; + this.config = config; + } + async create(...arguments_: unknown[]): Promise { + void arguments_; + return undefined; + } + async delete(...arguments_: unknown[]): Promise { + void arguments_; + return undefined; + } + async findById(...arguments_: unknown[]): Promise { + void arguments_; + return undefined; + } + } + class MockDefaultSqlFactory { + config: ApiConfig; + get table() { + return "files"; + } + constructor(config: ApiConfig) { + this.config = config; + } + } + return { + BaseService: MockBaseService, + DefaultSqlFactory: MockDefaultSqlFactory, + formatDate: (d: Date) => d.toISOString(), + }; +}); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const buildConfig = (s3Overrides: Record = {}): ApiConfig => + ({ + s3: { + bucket: "test-bucket", + clientConfig: {}, + ...s3Overrides, + }, + }) as unknown as ApiConfig; + +const mockFile: File = { + bucket: "test-bucket", + createdAt: Date.now(), + id: 1, + key: "test/file.txt", + originalFileName: "file.txt", + updatedAt: Date.now(), + uploadedAt: Date.now(), +}; + +const makePayload = (overrides: Record = {}) => ({ + file: { + fileContent: { + data: Buffer.from("data"), + encoding: "utf8", + filename: "report.pdf", + mimetype: "application/pdf", + }, + fileFields: {}, + }, + ...overrides, +}); + +// ── filename getter ─────────────────────────────────────────────────────────── + +describe("FileService — filename getter", async () => { + const { default: FileService } = await import("../model/files/service"); + + it("generates a UUID filename when none is set", () => { + const service = new FileService(buildConfig(), {}); + service.fileExtension = "pdf"; + expect(service.filename).toMatch( + /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}\.pdf$/i, + ); + }); + + it("appends the extension when the set filename lacks it", () => { + const service = new FileService(buildConfig(), {}); + service.fileExtension = "pdf"; + service.filename = "report"; + expect(service.filename).toBe("report.pdf"); + }); + + it("returns filename unchanged when it already ends with the extension", () => { + const service = new FileService(buildConfig(), {}); + service.fileExtension = "pdf"; + service.filename = "report.pdf"; + expect(service.filename).toBe("report.pdf"); + }); +}); + +// ── key getter ──────────────────────────────────────────────────────────────── + +describe("FileService — key getter", async () => { + const { default: FileService } = await import("../model/files/service"); + + it("returns just the filename when no path is set", () => { + const service = new FileService(buildConfig(), {}); + service.fileExtension = "txt"; + service.filename = "notes.txt"; + expect(service.key).toBe("notes.txt"); + }); + + it("appends a trailing slash to path before building the key", () => { + const service = new FileService(buildConfig(), {}); + service.fileExtension = "txt"; + service.filename = "notes.txt"; + service.path = "uploads/docs"; + expect(service.key).toBe("uploads/docs/notes.txt"); + }); + + it("does not double-slash when path already ends with /", () => { + const service = new FileService(buildConfig(), {}); + service.fileExtension = "txt"; + service.filename = "notes.txt"; + service.path = "uploads/docs/"; + expect(service.key).toBe("uploads/docs/notes.txt"); + }); +}); + +// ── upload ──────────────────────────────────────────────────────────────────── + +describe("FileService — upload", async () => { + const { default: FileService } = await import("../model/files/service"); + + let service: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + mockS3.isFileExists.mockResolvedValue(false); + mockS3.upload.mockResolvedValue({ ETag: "etag" }); + mockS3.getObjects.mockResolvedValue({ Contents: [] }); + service = new FileService(buildConfig(), {}); + }); + + it("throws FILE_ALREADY_EXISTS_IN_S3_ERROR when strategy is 'error' and file exists", async () => { + mockS3.isFileExists.mockResolvedValue(true); + + await expect( + service.upload({ + ...makePayload(), + options: { filenameResolutionStrategy: "error" }, + }), + ).rejects.toMatchObject({ code: ERROR_CODES.FILE_ALREADY_EXISTS_IN_S3 }); + }); + + it("uses config-level strategy when no per-upload strategy is provided", async () => { + mockS3.isFileExists.mockResolvedValue(true); + service = new FileService( + buildConfig({ filenameResolutionStrategy: "error" }), + {}, + ); + + await expect(service.upload(makePayload())).rejects.toMatchObject({ + code: ERROR_CODES.FILE_ALREADY_EXISTS_IN_S3, + }); + }); + + it("per-upload filenameResolutionStrategy overrides config-level strategy", async () => { + mockS3.isFileExists.mockResolvedValue(true); + service = new FileService( + buildConfig({ filenameResolutionStrategy: "error" }), + {}, + ); + vi.spyOn(service, "create").mockResolvedValue(mockFile); + + // Per-upload "add-suffix" overrides config "error" — no throw + await expect( + service.upload({ + ...makePayload(), + options: { filenameResolutionStrategy: "add-suffix" }, + }), + ).resolves.not.toThrow(); + }); + + it("appends a numeric suffix when strategy is 'add-suffix' and file exists", async () => { + mockS3.isFileExists.mockResolvedValue(true); + mockS3.getObjects.mockResolvedValue({ + Contents: [{ Key: "report-1.pdf" }], + }); + + const createSpy = vi.spyOn(service, "create").mockResolvedValue(mockFile); + + await service.upload({ + ...makePayload(), + options: { filenameResolutionStrategy: "add-suffix" }, + }); + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ key: expect.stringMatching(/-\d+\.pdf$/) }), + ); + }); + + it("uploads unconditionally when strategy is 'overwrite' and file exists", async () => { + mockS3.isFileExists.mockResolvedValue(true); + vi.spyOn(service, "create").mockResolvedValue(mockFile); + + await expect( + service.upload({ + ...makePayload(), + options: { filenameResolutionStrategy: "overwrite" }, + }), + ).resolves.not.toThrow(); + + expect(mockS3.upload).toHaveBeenCalledOnce(); + }); + + it("skips the conflict check when file does not exist and uploads directly", async () => { + vi.spyOn(service, "create").mockResolvedValue(mockFile); + + await service.upload(makePayload()); + + expect(mockS3.upload).toHaveBeenCalledOnce(); + expect(mockS3.getObjects).not.toHaveBeenCalled(); + }); +}); + +// ── download ────────────────────────────────────────────────────────────────── + +describe("FileService — download", async () => { + const { default: FileService } = await import("../model/files/service"); + + let service: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + service = new FileService(buildConfig(), {}); + }); + + it("throws FILE_NOT_FOUND_ERROR when the file is not in the DB", async () => { + vi.spyOn(service, "findById").mockResolvedValue(); + + await expect(service.download(999)).rejects.toMatchObject({ + code: ERROR_CODES.FILE_NOT_FOUND, + }); + }); + + it("returns file metadata with fileStream and mimeType when found", async () => { + vi.spyOn(service, "findById").mockResolvedValue(mockFile); + mockS3.get.mockResolvedValue({ + Body: Buffer.from("content"), + ContentType: "image/png", + }); + + const result = await service.download(1); + + expect(result.fileStream).toBeDefined(); + expect(result.mimeType).toBe("image/png"); + expect(result.key).toBe(mockFile.key); + }); +}); + +// ── presignedUrl ────────────────────────────────────────────────────────────── + +describe("FileService — presignedUrl", async () => { + const { default: FileService } = await import("../model/files/service"); + + let service: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + service = new FileService(buildConfig(), {}); + }); + + it("throws FILE_NOT_FOUND_ERROR when the file is not in the DB", async () => { + vi.spyOn(service, "findById").mockResolvedValue(); + + await expect(service.presignedUrl(999, {})).rejects.toMatchObject({ + code: ERROR_CODES.FILE_NOT_FOUND, + }); + }); + + it("returns file metadata with a signed URL when found", async () => { + vi.spyOn(service, "findById").mockResolvedValue(mockFile); + mockS3.generatePresignedUrl.mockResolvedValue( + "https://signed.url/file.txt", + ); + + const result = await service.presignedUrl(1, {}); + + expect(result.url).toBe("https://signed.url/file.txt"); + expect(result.id).toBe(mockFile.id); + }); +}); + +// ── deleteFile ──────────────────────────────────────────────────────────────── + +describe("FileService — deleteFile", async () => { + const { default: FileService } = await import("../model/files/service"); + + let service: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + service = new FileService(buildConfig(), {}); + }); + + it("throws FILE_NOT_FOUND_ERROR when the file is not in the DB", async () => { + vi.spyOn(service, "findById").mockResolvedValue(); + + await expect(service.deleteFile(999)).rejects.toMatchObject({ + code: ERROR_CODES.FILE_NOT_FOUND, + }); + }); + + it("deletes the S3 object after the DB record is removed", async () => { + vi.spyOn(service, "findById").mockResolvedValue(mockFile); + vi.spyOn(service, "delete").mockResolvedValue(true); + + await service.deleteFile(1); + + expect(mockS3.delete).toHaveBeenCalledWith(mockFile.key); + }); + + it("does not delete from S3 when the DB deletion returns falsy", async () => { + vi.spyOn(service, "findById").mockResolvedValue(mockFile); + vi.spyOn(service, "delete").mockResolvedValue(false); + + await service.deleteFile(1); + + expect(mockS3.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/s3/src/__test__/sqlFactory.test.ts b/packages/s3/src/__test__/sqlFactory.test.ts new file mode 100644 index 000000000..e4e985db0 --- /dev/null +++ b/packages/s3/src/__test__/sqlFactory.test.ts @@ -0,0 +1,106 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { Database } from "@prefabs.tech/fastify-slonik"; + +import { describe, expect, it, vi } from "vitest"; + +import { createFilesTableQuery } from "../migrations/queries"; + +vi.mock("@prefabs.tech/fastify-slonik", () => { + class MockDefaultSqlFactory { + config: ApiConfig; + get table() { + return "files"; + } + constructor(config: ApiConfig) { + this.config = config; + } + } + return { DefaultSqlFactory: MockDefaultSqlFactory }; +}); + +// ── FileSqlFactory ──────────────────────────────────────────────────────────── + +describe("FileSqlFactory — table name", async () => { + const { default: FileSqlFactory } = await import("../model/files/sqlFactory"); + + it("uses config.s3.table.name when set", () => { + const factory = new FileSqlFactory({ + s3: { table: { name: "documents" } }, + } as unknown as ApiConfig); + expect(factory.table).toBe("documents"); + }); + + it("falls back to the default 'files' table name when not set", () => { + const factory = new FileSqlFactory({ s3: {} } as unknown as ApiConfig); + expect(factory.table).toBe("files"); + }); + + it("falls back to the default table name when s3.table is undefined", () => { + const factory = new FileSqlFactory({ + s3: { table: undefined }, + } as unknown as ApiConfig); + expect(factory.table).toBe("files"); + }); +}); + +// ── runMigrations ───────────────────────────────────────────────────────────── + +describe("runMigrations", async () => { + const { default: runMigrations } = + await import("../migrations/runMigrations"); + + it("calls database.connect once on startup", async () => { + const mockConnection = { query: vi.fn().mockResolvedValue() }; + const mockDatabase = { + connect: vi + .fn() + .mockImplementation( + async (function_: (c: typeof mockConnection) => void) => + function_(mockConnection), + ), + }; + + await runMigrations( + mockDatabase as unknown as Database, + { s3: {} } as unknown as ApiConfig, + ); + + expect(mockDatabase.connect).toHaveBeenCalledOnce(); + }); + + it("executes exactly one query per migration run", async () => { + const mockConnection = { query: vi.fn().mockResolvedValue() }; + const mockDatabase = { + connect: vi + .fn() + .mockImplementation( + async (function_: (c: typeof mockConnection) => void) => + function_(mockConnection), + ), + }; + + await runMigrations( + mockDatabase as unknown as Database, + { s3: {} } as unknown as ApiConfig, + ); + + expect(mockConnection.query).toHaveBeenCalledOnce(); + }); +}); + +describe("createFilesTableQuery", () => { + it("uses the default 'files' table name when config.s3.table.name is not set", () => { + const query = createFilesTableQuery({ s3: {} } as unknown as ApiConfig); + + expect(query.sql).toContain("CREATE TABLE IF NOT EXISTS"); + expect(query.sql).toContain('"files"'); + }); + + it("uses config.s3.table.name when provided", () => { + const query = createFilesTableQuery({ + s3: { table: { name: "documents" } }, + } as unknown as ApiConfig); + + expect(query.sql).toContain('"documents"'); + }); +}); diff --git a/packages/s3/src/__test__/utils.test.ts b/packages/s3/src/__test__/utils.test.ts new file mode 100644 index 000000000..7b5913d62 --- /dev/null +++ b/packages/s3/src/__test__/utils.test.ts @@ -0,0 +1,163 @@ +import type { ListObjectsOutput } from "@aws-sdk/client-s3"; + +import { Readable } from "node:stream"; +import { describe, expect, it } from "vitest"; + +import { BUCKET_FROM_FILE_FIELDS, BUCKET_FROM_OPTIONS } from "../constants"; +import { + convertStreamToBuffer, + getBaseName, + getFileExtension, + getFilenameWithSuffix, + getPreferredBucket, +} from "../utils"; + +describe("getBaseName", () => { + it("removes the file extension", () => { + expect(getBaseName("document.pdf")).toBe("document"); + }); + + it("removes only the last extension when multiple dots are present", () => { + expect(getBaseName("archive.tar.gz")).toBe("archive.tar"); + }); + + it("returns the filename unchanged when no extension is present", () => { + expect(getBaseName("Makefile")).toBe("Makefile"); + }); +}); + +describe("getFileExtension", () => { + it("returns the extension without the leading dot", () => { + expect(getFileExtension("document.pdf")).toBe("pdf"); + }); + + it("returns the last extension when multiple dots are present", () => { + expect(getFileExtension("archive.tar.gz")).toBe("gz"); + }); + + it("returns empty string when there is no extension", () => { + expect(getFileExtension("Makefile")).toBe(""); + }); + + it("returns the text after the dot for dotfiles", () => { + expect(getFileExtension(".env")).toBe("env"); + }); +}); + +describe("getPreferredBucket", () => { + it("returns optionsBucket when bucketChoice is BUCKET_FROM_OPTIONS and optionsBucket is set", () => { + expect( + getPreferredBucket("opts-bucket", "file-bucket", BUCKET_FROM_OPTIONS), + ).toBe("opts-bucket"); + }); + + it("returns fileFieldsBucket when bucketChoice is BUCKET_FROM_FILE_FIELDS and fileFieldsBucket is set", () => { + expect( + getPreferredBucket("opts-bucket", "file-bucket", BUCKET_FROM_FILE_FIELDS), + ).toBe("file-bucket"); + }); + + it("returns fileFieldsBucket when only fileFieldsBucket is provided (no optionsBucket)", () => { + expect(getPreferredBucket(undefined, "file-bucket")).toBe("file-bucket"); + }); + + it("returns optionsBucket when only optionsBucket is provided (no fileFieldsBucket)", () => { + expect(getPreferredBucket("opts-bucket")).toBe("opts-bucket"); + }); + + it("returns the shared value when both buckets are equal and no bucketChoice is set", () => { + expect(getPreferredBucket("same", "same")).toBe("same"); + }); + + it("returns fileFieldsBucket when both differ and no bucketChoice is set", () => { + expect(getPreferredBucket("opts-bucket", "file-bucket")).toBe( + "file-bucket", + ); + }); + + it("returns undefined when neither bucket is provided", () => { + expect(getPreferredBucket()).toBeUndefined(); + }); + + it("falls back to fileFieldsBucket when bucketChoice is BUCKET_FROM_OPTIONS but optionsBucket is not set", () => { + expect( + getPreferredBucket(undefined, "file-bucket", BUCKET_FROM_OPTIONS), + ).toBe("file-bucket"); + }); + + it("falls back to optionsBucket when bucketChoice is BUCKET_FROM_FILE_FIELDS but fileFieldsBucket is not set", () => { + expect( + getPreferredBucket("opts-bucket", undefined, BUCKET_FROM_FILE_FIELDS), + ).toBe("opts-bucket"); + }); +}); + +describe("getFilenameWithSuffix", () => { + it("returns filename with suffix -1 when no suffixed files exist in listing", () => { + const listing: ListObjectsOutput = { Contents: [] }; + expect(getFilenameWithSuffix(listing, "report", "pdf")).toBe( + "report-1.pdf", + ); + }); + + it("returns filename with the next available suffix when suffixed files already exist", () => { + const listing: ListObjectsOutput = { + Contents: [{ Key: "report-1.pdf" }, { Key: "report-2.pdf" }], + }; + expect(getFilenameWithSuffix(listing, "report", "pdf")).toBe( + "report-3.pdf", + ); + }); + + it("returns filename with suffix -1 when Contents is undefined", () => { + const listing: ListObjectsOutput = {}; + expect(getFilenameWithSuffix(listing, "report", "pdf")).toBe( + "report-1.pdf", + ); + }); + + it("ignores keys that do not match the base name pattern", () => { + const listing: ListObjectsOutput = { + Contents: [{ Key: "other-file.pdf" }, { Key: "report.pdf" }], + }; + expect(getFilenameWithSuffix(listing, "report", "pdf")).toBe( + "report-1.pdf", + ); + }); + + it("picks the highest existing numeric suffix and increments it", () => { + const listing: ListObjectsOutput = { + Contents: [ + { Key: "report-1.pdf" }, + { Key: "report-5.pdf" }, + { Key: "report-3.pdf" }, + ], + }; + expect(getFilenameWithSuffix(listing, "report", "pdf")).toBe( + "report-6.pdf", + ); + }); +}); + +describe("convertStreamToBuffer", () => { + it("converts a readable stream to a buffer containing all chunks", async () => { + const stream = Readable.from([Buffer.from("hello"), Buffer.from(" world")]); + const buffer = await convertStreamToBuffer(stream); + expect(buffer.toString()).toBe("hello world"); + }); + + it("returns an empty buffer for an empty stream", async () => { + const stream = Readable.from([]); + const buffer = await convertStreamToBuffer(stream); + expect(buffer.length).toBe(0); + }); + + it("rejects when the stream emits an error", async () => { + const stream = new Readable({ + read() { + this.emit("error", new Error("stream error")); + }, + }); + await expect(convertStreamToBuffer(stream)).rejects.toThrow("stream error"); + }); +}); diff --git a/packages/s3/src/constants.ts b/packages/s3/src/constants.ts index b6f437aa7..576f408a9 100644 --- a/packages/s3/src/constants.ts +++ b/packages/s3/src/constants.ts @@ -7,8 +7,8 @@ const ADD_SUFFIX = "add-suffix"; const ERROR = "error"; const ERROR_CODES = { - FILE_NOT_FOUND: "FILE_NOT_FOUND_ERROR", FILE_ALREADY_EXISTS_IN_S3: "FILE_ALREADY_EXISTS_IN_S3_ERROR", + FILE_NOT_FOUND: "FILE_NOT_FOUND_ERROR", }; export { diff --git a/packages/s3/src/index.ts b/packages/s3/src/index.ts index 6fb39ce08..0f9778399 100644 --- a/packages/s3/src/index.ts +++ b/packages/s3/src/index.ts @@ -1,7 +1,8 @@ -import type { S3Config } from "./types"; // eslint-disable-next-line @typescript-eslint/no-unused-vars import type { GraphqlEnabledPlugin } from "@prefabs.tech/fastify-graphql"; +import type { S3Config } from "./types"; + declare module "@prefabs.tech/fastify-config" { interface ApiConfig { s3: S3Config; @@ -12,15 +13,15 @@ export * from "./constants"; export * from "./migrations/queries"; export { default as FileService } from "./model/files/service"; -export { default as S3Client } from "./utils/s3Client"; +export { default } from "./plugin"; +export { default as ajvFilePlugin } from "./plugins/ajvFile"; +export { default as multipartParserPlugin } from "./plugins/multipartParser"; export type { FilePayload, Multipart, S3Config } from "./types"; export type { File, FileCreateInput, FileUpdateInput } from "./types/file"; + +export { default as S3Client } from "./utils/s3Client"; +export type { S3ClientConfig } from "@aws-sdk/client-s3"; export type { FileUpload as GraphQLFileUpload, Upload as GraphQLUpload, } from "graphql-upload-minimal"; -export type { S3ClientConfig } from "@aws-sdk/client-s3"; - -export { default } from "./plugin"; -export { default as ajvFilePlugin } from "./plugins/ajvFile"; -export { default as multipartParserPlugin } from "./plugins/multipartParser"; diff --git a/packages/s3/src/migrations/queries.ts b/packages/s3/src/migrations/queries.ts index a3633cc39..e99601a05 100644 --- a/packages/s3/src/migrations/queries.ts +++ b/packages/s3/src/migrations/queries.ts @@ -1,10 +1,10 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { ZodTypeAny } from "zod"; + import { QuerySqlToken, sql } from "slonik"; import { TABLE_FILES } from "../constants"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; -import type { ZodTypeAny } from "zod"; - const createFilesTableQuery = ( config: ApiConfig, ): QuerySqlToken => { diff --git a/packages/s3/src/migrations/runMigrations.ts b/packages/s3/src/migrations/runMigrations.ts index acb9f8d7e..12669a266 100644 --- a/packages/s3/src/migrations/runMigrations.ts +++ b/packages/s3/src/migrations/runMigrations.ts @@ -1,8 +1,8 @@ -import { createFilesTableQuery } from "./queries"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Database } from "@prefabs.tech/fastify-slonik"; +import { createFilesTableQuery } from "./queries"; + const runMigrations = async (database: Database, config: ApiConfig) => { await database.connect(async (connection) => { await connection.query(createFilesTableQuery(config)); diff --git a/packages/s3/src/model/files/service.ts b/packages/s3/src/model/files/service.ts index 4a8480f63..c8ef5f8f4 100644 --- a/packages/s3/src/model/files/service.ts +++ b/packages/s3/src/model/files/service.ts @@ -2,28 +2,77 @@ import { CustomError } from "@prefabs.tech/fastify-error-handler"; import { BaseService, formatDate } from "@prefabs.tech/fastify-slonik"; import { v4 as uuidv4 } from "uuid"; -import FileSqlFactory from "./sqlFactory"; +import type { + File, + FileCreateInput, + FilePayload, + FileUpdateInput, + PresignedUrlOptions, +} from "../../types"; + import { ADD_SUFFIX, ERROR, ERROR_CODES } from "../../constants"; import { - getPreferredBucket, + getBaseName, getFileExtension, getFilenameWithSuffix, - getBaseName, + getPreferredBucket, } from "../../utils"; import S3Client from "../../utils/s3Client"; - -import type { - PresignedUrlOptions, - File, - FilePayload, - FileCreateInput, - FileUpdateInput, -} from "../../types"; +import FileSqlFactory from "./sqlFactory"; class FileService extends BaseService { - protected _filename: string = undefined as unknown as string; + get fileExtension() { + return this._fileExtension; + } + set fileExtension(fileExtension: string) { + this._fileExtension = fileExtension; + } + get filename() { + if (this._filename && !this._filename.endsWith(this.fileExtension)) { + return `${this._filename}.${this.fileExtension}`; + } + + return this._filename || `${uuidv4()}.${this.fileExtension}`; + } + set filename(filename: string) { + this._filename = filename; + } + + get key() { + let formattedPath = ""; + + if (this.path) { + formattedPath = this.path.endsWith("/") ? this.path : this.path + "/"; + } + + return `${formattedPath}${this.filename}`; + } + + get path() { + return this._path; + } + + set path(path: string) { + this._path = path; + } + + get s3Client() { + return ( + this._s3Client ?? + (this._s3Client = new S3Client(this.config.s3.clientConfig)) + ); + } + + get sqlFactoryClass() { + return FileSqlFactory; + } + protected _fileExtension: string = undefined as unknown as string; + + protected _filename: string = undefined as unknown as string; + protected _path: string = undefined as unknown as string; + protected _s3Client: S3Client | undefined; async deleteFile(fileId: number, options?: { bucket?: string }) { @@ -63,8 +112,8 @@ class FileService extends BaseService { return { ...file, - mimeType: s3Object?.ContentType, fileStream: s3Object.Body, + mimeType: s3Object?.ContentType, }; } @@ -94,12 +143,12 @@ class FileService extends BaseService { async upload(data: FilePayload) { const { fileContent, fileFields } = data.file; - const { filename, mimetype, data: fileData } = fileContent; + const { data: fileData, filename, mimetype } = fileContent; const { - path = "", bucket = "", bucketChoice, filenameResolutionStrategy, + path = "", } = data.options || {}; const fileExtension = getFileExtension(filename); @@ -155,63 +204,14 @@ class FileService extends BaseService { ...(fileFields?.lastDownloadedAt && { lastDownloadedAt: formatDate(new Date(fileFields.lastDownloadedAt)), }), - originalFileName: filename, key: key, + originalFileName: filename, } as unknown as FileCreateInput; const result = this.create(fileInput); return result; } - - get fileExtension() { - return this._fileExtension; - } - - get filename() { - if (this._filename && !this._filename.endsWith(this.fileExtension)) { - return `${this._filename}.${this.fileExtension}`; - } - - return this._filename || `${uuidv4()}.${this.fileExtension}`; - } - - get key() { - let formattedPath = ""; - - if (this.path) { - formattedPath = this.path.endsWith("/") ? this.path : this.path + "/"; - } - - return `${formattedPath}${this.filename}`; - } - - get path() { - return this._path; - } - - get s3Client() { - return ( - this._s3Client ?? - (this._s3Client = new S3Client(this.config.s3.clientConfig)) - ); - } - - get sqlFactoryClass() { - return FileSqlFactory; - } - - set fileExtension(fileExtension: string) { - this._fileExtension = fileExtension; - } - - set filename(filename: string) { - this._filename = filename; - } - - set path(path: string) { - this._path = path; - } } export default FileService; diff --git a/packages/s3/src/plugin.ts b/packages/s3/src/plugin.ts index bf545e219..3f4f33f6d 100644 --- a/packages/s3/src/plugin.ts +++ b/packages/s3/src/plugin.ts @@ -1,11 +1,11 @@ +import type { FastifyInstance } from "fastify"; + import fastifyMultiPart from "@fastify/multipart"; import FastifyPlugin from "fastify-plugin"; import runMigrations from "./migrations/runMigrations"; import graphqlGQLUpload from "./plugins/graphqlUpload"; -import type { FastifyInstance } from "fastify"; - const plugin = async (fastify: FastifyInstance) => { fastify.log.info("Registering fastify-s3 plugin"); @@ -16,19 +16,19 @@ const plugin = async (fastify: FastifyInstance) => { if (config.rest.enabled) { await fastify.register(fastifyMultiPart, { attachFieldsToBody: "keyValues", - sharedSchemaId: "fileSchema", limits: { fileSize: config.s3.fileSizeLimitInBytes || Number.POSITIVE_INFINITY, }, async onFile(part) { // @ts-expect-error: data value and data is missing in MultipartFile type part.value = { + data: await part.toBuffer(), + encoding: part.encoding, filename: part.filename, mimetype: part.mimetype, - encoding: part.encoding, - data: await part.toBuffer(), }; }, + sharedSchemaId: "fileSchema", }); } diff --git a/packages/s3/src/plugins/__test__/ajvFilePlugin.test.ts b/packages/s3/src/plugins/__test__/ajvFilePlugin.test.ts index 2b72cffbd..081b3ab4b 100644 --- a/packages/s3/src/plugins/__test__/ajvFilePlugin.test.ts +++ b/packages/s3/src/plugins/__test__/ajvFilePlugin.test.ts @@ -1,5 +1,5 @@ import Ajv from "ajv"; -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import plugin from "../ajvFile"; @@ -15,20 +15,20 @@ describe("ajvFile plugin", () => { plugin(ajv); const schema = { - type: "object", properties: { file: { isFile: true }, }, required: ["file"], + type: "object", }; const validate = ajv.compile(schema); const validData = { file: { + data: Buffer.from("test"), filename: "test.txt", mimetype: "text/plain", - data: Buffer.from("test"), }, }; const invalidData = { file: { name: "test.txt" } }; // Missing `filename` and `mimetype` @@ -51,14 +51,14 @@ describe("ajvFile plugin", () => { plugin(ajv); const schema = { - type: "object", properties: { files: { - type: "array", items: { isFile: true }, + type: "array", }, }, required: ["files"], + type: "object", }; const validate = ajv.compile(schema); @@ -66,14 +66,14 @@ describe("ajvFile plugin", () => { const validData = { files: [ { + data: Buffer.from("test"), filename: "test1.txt", mimetype: "text/plain", - data: Buffer.from("test"), }, { + data: Buffer.from("test"), filename: "test2.jpg", mimetype: "image/jpeg", - data: Buffer.from("test"), }, ], }; diff --git a/packages/s3/src/plugins/__test__/graphqlUpload.test.ts b/packages/s3/src/plugins/__test__/graphqlUpload.test.ts new file mode 100644 index 000000000..202c078f6 --- /dev/null +++ b/packages/s3/src/plugins/__test__/graphqlUpload.test.ts @@ -0,0 +1,60 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { afterEach, describe, expect, it } from "vitest"; + +describe("graphqlUpload plugin", () => { + let fastify: FastifyInstance; + + afterEach(async () => fastify.close()); + + it("does not modify request body when graphqlFileUploadMultipart is not set", async () => { + fastify = Fastify({ logger: false }); + const { default: plugin } = await import("../graphqlUpload"); + await fastify.register(plugin); + + let capturedBody: unknown; + fastify.post("/test", async (req) => { + capturedBody = req.body; + return {}; + }); + + await fastify.ready(); + + await fastify.inject({ + headers: { "content-type": "application/json" }, + method: "POST", + payload: JSON.stringify({ original: true }), + url: "/test", + }); + + expect(capturedBody).toEqual({ original: true }); + }); + + it("does not modify request body when graphqlFileUploadMultipart is explicitly false", async () => { + fastify = Fastify({ logger: false }); + const { default: plugin } = await import("../graphqlUpload"); + await fastify.register(plugin); + + fastify.addHook("onRequest", async (req) => { + req.graphqlFileUploadMultipart = false; + }); + + let capturedBody: unknown; + fastify.post("/test", async (req) => { + capturedBody = req.body; + return {}; + }); + + await fastify.ready(); + + await fastify.inject({ + headers: { "content-type": "application/json" }, + method: "POST", + payload: JSON.stringify({ original: true }), + url: "/test", + }); + + expect(capturedBody).toEqual({ original: true }); + }); +}); diff --git a/packages/s3/src/plugins/ajvFile.ts b/packages/s3/src/plugins/ajvFile.ts index 69ace8600..69bb5deaa 100644 --- a/packages/s3/src/plugins/ajvFile.ts +++ b/packages/s3/src/plugins/ajvFile.ts @@ -17,7 +17,6 @@ const validateFile = (data: unknown): boolean => { export default function plugin(ajv: Ajv): Ajv { return ajv.addKeyword({ - keyword: "isFile", compile: (_schema: boolean, parentSchema: AnySchemaObject) => { const schema = parentSchema; if (schema.type === "array") { @@ -40,5 +39,6 @@ export default function plugin(ajv: Ajv): Ajv { error: { message: "should be a file or array of files", }, + keyword: "isFile", }); } diff --git a/packages/s3/src/plugins/graphqlUpload.ts b/packages/s3/src/plugins/graphqlUpload.ts index e2a62c014..65fda6000 100644 --- a/packages/s3/src/plugins/graphqlUpload.ts +++ b/packages/s3/src/plugins/graphqlUpload.ts @@ -1,8 +1,8 @@ +import type { FastifyPluginCallback } from "fastify"; + import fastifyPlugin from "fastify-plugin"; import { processRequest, UploadOptions } from "graphql-upload-minimal"; -import type { FastifyPluginCallback } from "fastify"; - declare module "fastify" { interface FastifyRequest { graphqlFileUploadMultipart?: boolean; diff --git a/packages/s3/src/plugins/multipartParser.ts b/packages/s3/src/plugins/multipartParser.ts index 9ad790cea..2b45712d9 100644 --- a/packages/s3/src/plugins/multipartParser.ts +++ b/packages/s3/src/plugins/multipartParser.ts @@ -1,9 +1,9 @@ +import type { FastifyInstance } from "fastify"; + import fastifyPlugin from "fastify-plugin"; import { processMultipartFormData } from "../utils"; -import type { FastifyInstance } from "fastify"; - declare module "fastify" { interface FastifyRequest { graphqlFileUploadMultipart?: boolean; diff --git a/packages/s3/src/types/file.ts b/packages/s3/src/types/file.ts index 2ef0ca8a4..1a639ad7f 100644 --- a/packages/s3/src/types/file.ts +++ b/packages/s3/src/types/file.ts @@ -1,22 +1,22 @@ interface File { - id: number; - originalFileName: string; bucket?: string; + createdAt: number; description?: string; - key: string; - uploadedById?: string; - uploadedAt: number; downloadCount?: number; + id: number; + key: string; lastDownloadedAt?: number; - createdAt: number; + originalFileName: string; updatedAt: number; + uploadedAt: number; + uploadedById?: string; } type FileCreateInput = Omit< File, - "id" | "originalFileName" | "key" | "createdAt" | "updatedAt" + "createdAt" | "id" | "key" | "originalFileName" | "updatedAt" >; -type FileUpdateInput = Partial>; +type FileUpdateInput = Partial>; export type { File, FileCreateInput, FileUpdateInput }; diff --git a/packages/s3/src/types/index.ts b/packages/s3/src/types/index.ts index e6b5481aa..f17f9b714 100644 --- a/packages/s3/src/types/index.ts +++ b/packages/s3/src/types/index.ts @@ -1,5 +1,9 @@ +import type { S3ClientConfig } from "@aws-sdk/client-s3"; + import { ReadStream } from "node:fs"; +import type { FileCreateInput } from "./file"; + import { ADD_SUFFIX, BUCKET_FROM_FILE_FIELDS, @@ -8,29 +12,16 @@ import { OVERWRITE, } from "../constants"; -import type { FileCreateInput } from "./file"; -import type { S3ClientConfig } from "@aws-sdk/client-s3"; - +interface BaseOption { + bucket?: string; +} type BucketChoice = typeof BUCKET_FROM_FILE_FIELDS | typeof BUCKET_FROM_OPTIONS; + type FilenameResolutionStrategy = | typeof ADD_SUFFIX | typeof ERROR | typeof OVERWRITE; -interface BaseOption { - bucket?: string; -} - -interface PresignedUrlOptions extends BaseOption { - signedUrlExpiresInSecond?: number; -} - -interface FilePayloadOptions extends BaseOption { - bucketChoice?: BucketChoice; - filenameResolutionStrategy?: FilenameResolutionStrategy; - path?: string; -} - interface FilePayload { file: { fileContent: Multipart; @@ -39,18 +30,28 @@ interface FilePayload { options?: FilePayloadOptions; } +interface FilePayloadOptions extends BaseOption { + bucketChoice?: BucketChoice; + filenameResolutionStrategy?: FilenameResolutionStrategy; + path?: string; +} + interface Multipart { data: Buffer | ReadStream; - filename: string; encoding?: string; - mimetype: string; + filename: string; limit?: boolean; + mimetype: string; +} + +interface PresignedUrlOptions extends BaseOption { + signedUrlExpiresInSecond?: number; } interface S3Config { - bucket: string | Record; + bucket: Record | string; clientConfig: S3ClientConfig; - fileSizeLimitInBytes?: number; filenameResolutionStrategy?: FilenameResolutionStrategy; + fileSizeLimitInBytes?: number; table?: { name?: string; }; @@ -58,11 +59,11 @@ interface S3Config { export type { BucketChoice, - PresignedUrlOptions, + FilenameResolutionStrategy, FilePayload, FilePayloadOptions, - FilenameResolutionStrategy, Multipart, + PresignedUrlOptions, S3Config, }; diff --git a/packages/s3/src/utils/index.ts b/packages/s3/src/utils/index.ts index 27473f2f3..6de1febae 100644 --- a/packages/s3/src/utils/index.ts +++ b/packages/s3/src/utils/index.ts @@ -1,14 +1,14 @@ +import type { ListObjectsOutput } from "@aws-sdk/client-s3"; +import type { FastifyRequest } from "fastify"; + +import Busboy, { FileInfo } from "busboy"; import { IncomingMessage } from "node:http"; import { Readable } from "node:stream"; -import Busboy, { FileInfo } from "busboy"; +import type { BucketChoice, Multipart } from "../types"; import { BUCKET_FROM_FILE_FIELDS, BUCKET_FROM_OPTIONS } from "../constants"; -import type { BucketChoice, Multipart } from "../types"; -import type { ListObjectsOutput } from "@aws-sdk/client-s3"; -import type { FastifyRequest } from "fastify"; - const convertStreamToBuffer = async (stream: Readable): Promise => { return new Promise((resolve, reject) => { const chunks: Uint8Array[] = []; @@ -124,8 +124,8 @@ const processMultipartFormData = ( files[fieldName].push({ ...fileInfo, - mimetype: fileInfo.mimeType, data: fileBuffer, + mimetype: fileInfo.mimeType, }); }); }, @@ -152,7 +152,7 @@ export { convertStreamToBuffer, getBaseName, getFileExtension, - getPreferredBucket, getFilenameWithSuffix, + getPreferredBucket, processMultipartFormData, }; diff --git a/packages/s3/src/utils/s3Client.ts b/packages/s3/src/utils/s3Client.ts index 31bde376f..da6efe137 100644 --- a/packages/s3/src/utils/s3Client.ts +++ b/packages/s3/src/utils/s3Client.ts @@ -1,29 +1,40 @@ -import { ReadStream } from "node:fs"; -import { Readable } from "node:stream"; +import type { + AbortMultipartUploadCommandOutput, + CompleteMultipartUploadCommandOutput, + DeleteObjectCommandOutput, + ListObjectsCommandOutput, + S3ClientConfig, +} from "@aws-sdk/client-s3"; import { - S3Client, - GetObjectCommand, DeleteObjectCommand, + GetObjectCommand, HeadObjectCommand, ListObjectsCommand, + S3Client, } from "@aws-sdk/client-s3"; import { Upload } from "@aws-sdk/lib-storage"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { ReadStream } from "node:fs"; +import { Readable } from "node:stream"; import { convertStreamToBuffer } from "."; -import type { - AbortMultipartUploadCommandOutput, - CompleteMultipartUploadCommandOutput, - DeleteObjectCommandOutput, - ListObjectsCommandOutput, - S3ClientConfig, -} from "@aws-sdk/client-s3"; - class s3Client { + get bucket() { + return this._bucket; + } + set bucket(bucket: string) { + this._bucket = bucket; + } + get config() { + return this._config; + } + protected _bucket: string = undefined as unknown as string; + protected _config: S3ClientConfig; + protected _storageClient: S3Client; constructor(config: S3ClientConfig) { @@ -31,18 +42,6 @@ class s3Client { this._storageClient = this.init(); } - get config() { - return this._config; - } - - get bucket() { - return this._bucket; - } - - set bucket(bucket: string) { - this._bucket = bucket; - } - /** * Deletes an object from the Amazon S3 bucket. * @param {string} filePath - The path of the object to delete in the S3 bucket. @@ -100,37 +99,24 @@ class s3Client { const streamValue = await convertStreamToBuffer(stream); return { - ContentType: response.ContentType, Body: streamValue, + ContentType: response.ContentType, }; } /** - * Uploads a file to the specified S3 bucket. + * Retrieves a list of objects from the S3 bucket with a specified prefix. * - * @param {Buffer} fileStream - The file content as a Buffer. - * @param {string} key - The key (file name) to use when storing the file in the bucket. - * @param {string} mimetype - The MIME type of the file. - * @returns {Promise} A Promise that resolves with information about the uploaded object. + * @param {string} baseName - The prefix used to filter objects within the S3 bucket. + * @returns {Promise} A Promise that resolves to the result of the list operation. */ - public async upload( - fileStream: Buffer | ReadStream, - key: string, - mimetype: string, - ): Promise< - AbortMultipartUploadCommandOutput | CompleteMultipartUploadCommandOutput - > { - const putCommand = new Upload({ - client: this._storageClient, - params: { + public async getObjects(baseName: string): Promise { + return await this._storageClient.send( + new ListObjectsCommand({ Bucket: this.bucket, - Key: key, - Body: fileStream, - ContentType: mimetype, - }, - }); - - return await putCommand.done(); + Prefix: baseName, + }), + ); } /** @@ -159,18 +145,31 @@ class s3Client { } /** - * Retrieves a list of objects from the S3 bucket with a specified prefix. + * Uploads a file to the specified S3 bucket. * - * @param {string} baseName - The prefix used to filter objects within the S3 bucket. - * @returns {Promise} A Promise that resolves to the result of the list operation. + * @param {Buffer} fileStream - The file content as a Buffer. + * @param {string} key - The key (file name) to use when storing the file in the bucket. + * @param {string} mimetype - The MIME type of the file. + * @returns {Promise} A Promise that resolves with information about the uploaded object. */ - public async getObjects(baseName: string): Promise { - return await this._storageClient.send( - new ListObjectsCommand({ + public async upload( + fileStream: Buffer | ReadStream, + key: string, + mimetype: string, + ): Promise< + AbortMultipartUploadCommandOutput | CompleteMultipartUploadCommandOutput + > { + const putCommand = new Upload({ + client: this._storageClient, + params: { + Body: fileStream, Bucket: this.bucket, - Prefix: baseName, - }), - ); + ContentType: mimetype, + Key: key, + }, + }); + + return await putCommand.done(); } protected init(): S3Client { diff --git a/packages/s3/vite.config.ts b/packages/s3/vite.config.ts index 2d63986aa..701cda0df 100644 --- a/packages/s3/vite.config.ts +++ b/packages/s3/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { dependencies, peerDependencies } from "./package.json"; @@ -29,21 +28,21 @@ export default defineConfig(({ mode }) => { "@aws-sdk/client-s3": "AWSClientS3", "@aws-sdk/lib-storage": "AWSLibStorage", "@aws-sdk/s3-request-presigner": "AWSS3RequestPresigner", + "@fastify/cors": "FastifyCors", + "@fastify/formbody": "FastifyFormbody", + "@fastify/multipart": "FastifyMultipart", "@prefabs.tech/fastify-config": "PrefabsTechFastifyConfig", "@prefabs.tech/fastify-error-handler": "PrefabsTechFastifyErrorHandler", "@prefabs.tech/fastify-graphql": "PrefabsTechFastifyGraphql", "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", - "@fastify/cors": "FastifyCors", - "@fastify/formbody": "FastifyFormbody", - "@fastify/multipart": "FastifyMultipart", busboy: "Busboy", fastify: "Fastify", "fastify-plugin": "FastifyPlugin", "graphql-upload-minimal": "graphqlUploadMinimal", slonik: "Slonik", - zod: "zod", uuid: "uuid", + zod: "zod", }, }, }, diff --git a/packages/slonik/FEATURES.md b/packages/slonik/FEATURES.md new file mode 100644 index 000000000..6c2a041b8 --- /dev/null +++ b/packages/slonik/FEATURES.md @@ -0,0 +1,187 @@ + + +## Plugin Registration + +1. **Main plugin (default export)** — Registers as a Fastify 5 plugin (via `fastify-plugin`). Accepts `SlonikOptions` directly. When called with no options, falls back to `fastify.config.slonik` and logs a deprecation warning. Throws a descriptive error if neither source provides configuration. + +2. **Idempotent decorator registration** — Checks `fastify.hasDecorator` and `fastify.hasRequestDecorator` before decorating, so the internal `fastifySlonik` plugin can be registered multiple times without conflict. + +3. **Connection verification on startup** — After creating the pool, calls `pool.connect()` to verify the database is reachable. Logs success (`"Connected to Postgres DB"`) or error (`"Error happened while connecting to Postgres DB"`) and rethrows on failure. + +4. **Auto-provisioned PostgreSQL extensions** — On startup, runs `CREATE EXTENSION IF NOT EXISTS` for `citext` and `unaccent` by default. Merges and deduplicates with any extensions listed in `options.extensions`. + +5. **`migrationPlugin`** — Standalone Fastify plugin that runs SQL file migrations via `@prefabs.tech/postgres-migrations`. Applies the same config-fallback logic (direct options or `fastify.config.slonik`). Default migration directory is `"migrations"`. + +## Fastify Decorators + +6. **`fastify.slonik`** — Decorates the Fastify instance with a `Database` object `{ pool, connect, query }` wrapping the Slonik `DatabasePool`. + +7. **`fastify.sql`** — Decorates the Fastify instance with slonik's `sql` tagged-template helper. + +8. **`request.slonik`** — Decorates every `FastifyRequest` with the same `Database` object, populated via an `onRequest` hook. + +9. **`request.sql`** — Decorates every `FastifyRequest` with the `sql` helper, populated via the same `onRequest` hook. + +10. **`request.dbSchema`** — Decorates every `FastifyRequest` with an empty string (`""`). Consuming code sets this to support per-request schema routing. + +## Module Augmentation + +11. **`FastifyInstance` augmentation** — Extends `fastify`'s `FastifyInstance` interface with `slonik: Database` and `sql: typeof sql`. + +12. **`FastifyRequest` augmentation** — Extends `fastify`'s `FastifyRequest` interface with `slonik: Database`, `sql: typeof sql`, and `dbSchema: string`. + +13. **`ApiConfig` augmentation** — Extends `@prefabs.tech/fastify-config`'s `ApiConfig` interface with `slonik: SlonikConfig`. + +## Client Configuration + +14. **`createClientConfiguration` factory** — Builds a `ClientConfigurationInput` with opinionated defaults: `captureStackTrace: false`, `connectionRetryLimit: 3`, `connectionTimeout: 5000 ms`, `idleInTransactionSessionTimeout: 60000 ms`, `idleTimeout: 5000 ms`, `maximumPoolSize: 10`, `queryRetryLimit: 5`, `statementTimeout: 60000 ms`, `transactionRetryLimit: 5`. Caller-supplied `config` is shallow-merged on top. + +15. **Built-in interceptor chain** — `fieldNameCaseConverter` (snake_case → camelCase via `humps.camelizeKeys`) and `resultParser` (Zod row validation) are always prepended to the interceptor list, before any optional or user-supplied interceptors. + +16. **Optional query-logging interceptor** — Added to the chain (after built-in interceptors) when `options.queryLogging.enabled === true`. Uses `slonik-interceptor-query-logging`. Requires `ROARR_LOG=true` at runtime. + +17. **User interceptors merged** — Interceptors in `clientConfiguration.interceptors` are appended after built-in and logging interceptors. + +18. **Extended type parsers** — `createTypeParserPreset()` (slonik built-ins) plus `createBigintTypeParser()` (`int8` → `Number.parseInt`) are registered on every pool. + +## Field Name Conversion + +19. **Automatic snake_case → camelCase** — The `fieldNameCaseConverter` interceptor calls `humps.camelizeKeys()` on every query result row, so DB columns like `created_at` become `createdAt` in application code. + +## Result Validation + +20. **Zod row validation** — The `resultParser` interceptor validates each row against the Zod schema passed to `sql.type(...)`. Throws `SchemaValidationError` on failure. Passes rows through unchanged when no schema is attached to the query. + +## Type Parsers + +21. **`createBigintTypeParser`** — Exported factory returning a `DriverTypeParser` that maps the `int8` OID to `Number.parseInt`, preventing PostgreSQL `bigint` columns from being returned as strings. + +## Database Creation + +22. **`createDatabase` utility** — Exported function that creates a Slonik pool and wraps it in the `Database` interface `{ pool, connect, query }`. + +## SQL Fragment Helpers + +23. **`createFilterFragment`** — Builds a `FragmentSqlToken` from a `FilterInput`; returns an empty fragment when `filters` is `undefined`. + +24. **`createLimitFragment`** — Builds `LIMIT n` or `LIMIT n OFFSET m` as a `FragmentSqlToken`. + +25. **`createSortFragment`** — Builds `ORDER BY col [ASC|DESC] [, ...]` from a `SortInput[]`. Supports dot-notation keys, camelCase-to-snake_case conversion, and `unaccent(lower(...))` for accent/case-insensitive sorting. + +26. **`createTableFragment`** — Builds a `FragmentSqlToken` referencing `schema.table` or just `table`. + +27. **`createTableIdentifier`** — Builds an `IdentifierSqlToken` for `[schema, table]` or `[table]`. + +28. **`createWhereFragment`** — Merges a `FilterInput`, additional `FragmentSqlToken[]`, and an optional `IdentifierSqlToken` into a single `WHERE … AND …` fragment, or an empty fragment when nothing applies. Strips any leading `WHERE` keyword from provided fragments to avoid duplication. + +29. **`createWhereIdFragment`** — Builds a `WHERE id = $1` fragment. + +30. **`isValueExpression`** — Type-guard that returns `true` for values usable as a Slonik `ValueExpression` (`null`, `string`, `number`, `boolean`, `Date`, `Buffer`, or a uniform array thereof). + +## Filter System + +31. **Filter operators** — `eq` (equals), `ct` (ILIKE `%value%`), `sw` (ILIKE `value%`), `ew` (ILIKE `%value`), `gt` (`>`), `gte` (`>=`), `lt` (`<`), `lte` (`<=`), `in` (comma-separated list), `bt` (BETWEEN, comma-separated bounds), `dwithin` (PostGIS geography radius, `"lat,lng,radius_m"`). + +32. **`not` flag** — Adding `not: true` (or `"true"` / `"1"`) to any filter negates the condition (e.g., `!=`, `NOT IN`, `NOT BETWEEN`, `IS NOT NULL`). + +33. **`insensitive` flag** — Adding `insensitive: true` wraps both field and value in `unaccent(lower(...))` for accent- and case-insensitive comparisons. Works with `eq`, `ct`, `sw`, `ew`, `gt`, `gte`, `lt`, `lte`, `in`, `bt`. + +34. **NULL check via `value: "null"`** — When `operator: "eq"` and `value` is `"null"` or `"NULL"`, generates `IS NULL` or `IS NOT NULL` (with `not: true`). + +35. **`in` operator validation** — Throws `Error("IN operator requires at least one value")` if the comma-separated list is empty. + +36. **`bt` (between) operator validation** — Throws `Error("BETWEEN operator requires exactly two values")` if either bound is missing. + +37. **Recursive AND/OR composition** — `FilterInput` can be `{ AND: FilterInput[] }` or `{ OR: FilterInput[] }` at any nesting depth. Empty arrays produce an empty fragment (no condition). + +38. **`applyFiltersToQuery`** — Wraps `buildFilterFragment` output in a `WHERE` clause, or returns an empty fragment if there are no conditions. + +## DefaultSqlFactory + +39. **`DefaultSqlFactory` class** — Concrete `SqlFactory` implementation. Requires a static `TABLE` name on the subclass. Constructor accepts `config: ApiConfig`, `database: Database`, and optional `schema` string (defaults to `"public"`). + +40. **Static defaults** — `LIMIT_DEFAULT = 20`, `LIMIT_MAX = 50`, `SORT_DIRECTION = "ASC"`, `SORT_KEY = "id"`. + +41. **Config-driven pagination** — `limitDefault` and `limitMax` are read from `config.slonik.pagination.defaultLimit` / `maxLimit` when present, falling back to static defaults. + +42. **Soft-delete support** — Protected `_softDeleteEnabled = false`. When set to `true`, `getDeleteSql` issues `UPDATE … SET deleted_at = NOW()` instead of `DELETE` (unless `force = true`). All read queries automatically append `deleted_at IS NULL`. + +43. **Zod validation schema** — `validationSchema` property (default `z.any()`). All generated queries use `sql.type(validationSchema)` so rows are parsed by Zod on return. + +44. **camelCase → snake_case column mapping** — `getCreateSql` and `getUpdateSql` call `humps.decamelize` on every key, so callers pass camelCase field names. + +45. **`getAllSql`** — Generates `SELECT FROM [WHERE …] [ORDER BY …]`. Narrows the Zod schema to requested fields when the factory schema is a `ZodObject`. + +46. **`getCountSql`** — Generates `SELECT COUNT(*) FROM
[WHERE …]` validated with `z.object({ count: z.number() })`. + +47. **`getCreateSql`** — Generates `INSERT INTO
(…) VALUES (…) RETURNING *`. + +48. **`getDeleteSql`** — Generates `DELETE FROM
WHERE id = $1 RETURNING *` (or soft-delete UPDATE when enabled). + +49. **`getFindByIdSql`** — Generates `SELECT * FROM
WHERE id = $1`. + +50. **`getFindOneSql`** — Generates `SELECT * FROM
[WHERE …] [ORDER BY …] LIMIT 1`. + +51. **`getFindSql`** — Generates `SELECT * FROM
[WHERE …] [ORDER BY …]`. + +52. **`getListSql`** — Generates `SELECT * FROM
[WHERE …] [ORDER BY …] LIMIT n [OFFSET m]`. Limit is clamped to `limitMax`. + +53. **`getUpdateSql`** — Generates `UPDATE
SET col = $1 [, …] WHERE id = $n RETURNING *`. + +54. **`getAdditionalFilterFragments` hook** — Protected method returning `[]` by default; subclasses override to inject extra WHERE conditions into every read query. + +55. **`getTableFragment` (deprecated)** — Returns `this.tableFragment`. Use the `tableFragment` getter directly. + +## BaseService + +56. **`BaseService` abstract class** — Generic `BaseService` implementing `Service`. Constructor accepts `config: ApiConfig`, `database: Database`, and optional `schema` string (defaults to `"public"`). + +57. **Lazy factory instantiation** — The `SqlFactory` instance is created on first access of `this.factory`. Subclasses override `get sqlFactoryClass()` to supply a custom factory. + +58. **`all(fields, sort?)`** — Fetches all rows with a restricted field set. + +59. **`count(filters?)`** — Returns total row count, optionally filtered. + +60. **`create(data)`** — Inserts one row and returns the created entity, or `undefined` if the DB returns nothing. + +61. **`delete(id, force?)`** — Deletes (or soft-deletes) by `id`. Returns the deleted entity or `null`. + +62. **`find(filters?, sort?)`** — Returns all rows matching optional filters and sort. + +63. **`findById(id)`** — Returns one row by primary key or `null`. + +64. **`findOne(filters?, sort?)`** — Returns the first matching row or `null`. + +65. **`list(limit?, offset?, filters?, sort?)`** — Returns `PaginatedList` with `{ data, totalCount, filteredCount }`. Total count and filtered count are fetched concurrently with the data query. + +66. **`update(id, data)`** — Updates a row by `id` and returns the updated entity. + +67. **Pre/post lifecycle hooks** — Optional `pre` / `post` protected methods (e.g., `preCreate`, `postCreate`) are called before and after every DB operation. Subclasses override to transform input data or output results. Hook results are validated for type compatibility before being applied. + +## Standalone Migration Utility + +68. **`migrate` utility** — Builds a `pg.Client` from `SlonikOptions.db` (includes SSL from `clientConfiguration.ssl` when set), runs `@prefabs.tech/postgres-migrations`, then disconnects. Default migrations path is `"migrations"`. + +## Type Exports + +69. **`SlonikConfig` / `SlonikOptions`** — Plugin configuration: `db` (required `ConnectionOptions`), optional `clientConfiguration`, `extensions`, `migrations.path`, `pagination.defaultLimit`/`maxLimit`, `queryLogging.enabled`. + +70. **`Database`** — `{ pool: DatabasePool; connect; query }` — the shape decorated onto the Fastify instance and requests. + +71. **`BaseFilterInput`** — Single filter condition shape: `key`, `operator`, `value`, optional `not`, `insensitive`. + +72. **`FilterInput`** — Recursive union: `BaseFilterInput | { AND: FilterInput[] } | { OR: FilterInput[] }`. + +73. **`SortInput`** — `{ key: string; direction: SortDirection; insensitive?: boolean | string }`. + +74. **`SortDirection`** — `"ASC" | "DESC"`. + +75. **`PaginatedList`** — `{ data: readonly T[]; totalCount: number; filteredCount: number }`. + +76. **`Service`** — Interface contract for service classes. + +77. **`SqlFactory`** — Interface contract for SQL factory classes. + +## Utility Exports + +78. **`formatDate(date)`** — Formats a `Date` as `"YYYY-MM-DD HH:mm:ss.SSS"` (ISO string truncated to 23 chars, `T` replaced with a space) — suitable for PostgreSQL timestamp columns. diff --git a/packages/slonik/GUIDE.md b/packages/slonik/GUIDE.md new file mode 100644 index 000000000..4cf7c9332 --- /dev/null +++ b/packages/slonik/GUIDE.md @@ -0,0 +1,930 @@ +# @prefabs.tech/fastify-slonik — Developer Guide + +## Installation + +### For package consumers (npm + pnpm) + +```bash +# npm +npm install @prefabs.tech/fastify-slonik slonik fastify fastify-plugin zod + +# pnpm +pnpm add @prefabs.tech/fastify-slonik slonik fastify fastify-plugin zod +``` + +Peer dependencies that are always required: + +| Peer | Version | +| ------------------------------ | ---------- | +| `fastify` | `>=5.2.1` | +| `fastify-plugin` | `>=5.0.1` | +| `slonik` | `>=46.1.0` | +| `zod` | `>=3.23.8` | +| `@prefabs.tech/fastify-config` | `0.93.5` | + +`pg-mem` is an optional peer — only needed for in-memory testing: + +```bash +pnpm add -D pg-mem +``` + +### For monorepo development (pnpm install / test / build) + +```bash +# from the repo root +pnpm install + +# run tests for this package only +pnpm --filter @prefabs.tech/fastify-slonik test + +# build +pnpm --filter @prefabs.tech/fastify-slonik build +``` + +--- + +## Setup + +Register the plugin once during application bootstrap. All later examples assume this setup is in place. + +```typescript +import Fastify from "fastify"; +import slonikPlugin from "@prefabs.tech/fastify-slonik"; + +const fastify = Fastify({ logger: true }); + +await fastify.register(slonikPlugin, { + db: { + host: "localhost", + port: 5432, + databaseName: "mydb", + username: "app", + password: "secret", + }, + // optional: run SQL migrations on startup + migrations: { + path: "migrations", // default: "migrations" + }, + // optional: add extra PostgreSQL extensions (citext + unaccent are always added) + extensions: ["postgis"], + // optional: enable query logging (requires ROARR_LOG=true at runtime) + queryLogging: { + enabled: process.env.NODE_ENV !== "production", + }, + // optional: override pagination defaults + pagination: { + defaultLimit: 25, + maxLimit: 100, + }, + // optional: override any slonik ClientConfigurationInput defaults + clientConfiguration: { + maximumPoolSize: 20, + }, +}); + +await fastify.listen({ port: 3000 }); +``` + +After registration the following are available everywhere in your application: + +| Symbol | Available on | +| ------------------ | ---------------------- | +| `fastify.slonik` | Fastify instance | +| `fastify.sql` | Fastify instance | +| `request.slonik` | Every `FastifyRequest` | +| `request.sql` | Every `FastifyRequest` | +| `request.dbSchema` | Every `FastifyRequest` | + +--- + +## Base Libraries + +### slonik — Partial Passthrough + +→ Their docs: [https://www.npmjs.com/package/slonik](https://www.npmjs.com/package/slonik) + +slonik provides the type-safe PostgreSQL client (`createPool`, `sql`, `DatabasePool`, `ConnectionRoutine`, etc.). This package does **not** re-export slonik's pool directly — it wraps it in the `Database` interface and surfaces it via Fastify decorators. You interact with the pool through `fastify.slonik.pool`, `fastify.slonik.connect(...)`, and `fastify.slonik.query(...)`. + +What we add on top: + +- Opinionated `ClientConfigurationInput` defaults (see [Client Configuration defaults](#feature-14-createclientconfiguration-factory)). +- Two always-active interceptors: snake_case → camelCase conversion and Zod row validation. +- Auto-provisioned PostgreSQL extensions on startup. +- The `Database` wrapper type exported from this package. + +### fastify-plugin — Full Passthrough + +→ Their docs: [https://www.npmjs.com/package/fastify-plugin](https://www.npmjs.com/package/fastify-plugin) + +Used internally to register our plugins without encapsulation (so decorators are visible to the parent scope). You do not interact with `fastify-plugin` directly. + +### @prefabs.tech/fastify-config — Partial Passthrough + +→ Their docs: internal monorepo package (`packages/config`). + +We augment the `ApiConfig` interface from this package with a `slonik: SlonikConfig` property. This makes `fastify.config.slonik` the fallback config source when no options are passed to the plugin directly. + +### @prefabs.tech/postgres-migrations — Full Passthrough + +→ Their docs: [https://www.npmjs.com/package/@prefabs.tech/postgres-migrations](https://www.npmjs.com/package/@prefabs.tech/postgres-migrations) + +Used internally by `migrate.ts` and `migrationPlugin`. You never call it directly — the migration plugin and the `migrate` utility function handle it. + +### humps — Full Passthrough + +→ Their docs: [https://www.npmjs.com/package/humps](https://www.npmjs.com/package/humps) + +Used internally by the `fieldNameCaseConverter` interceptor and by `DefaultSqlFactory` for camelCase ↔ snake_case column name mapping. Not re-exported. + +### slonik-interceptor-query-logging — Full Passthrough + +→ Their docs: [https://www.npmjs.com/package/slonik-interceptor-query-logging](https://www.npmjs.com/package/slonik-interceptor-query-logging) + +Used internally when `queryLogging.enabled === true`. Not re-exported. + +### zod — Partial Passthrough + +→ Their docs: [https://zod.dev](https://zod.dev) + +Used internally by `resultParser` and `DefaultSqlFactory`. You supply Zod schemas to your `DefaultSqlFactory` subclass via `_validationSchema`. Zod itself is not re-exported from this package. + +--- + +## Features + +### Feature 1 — Main plugin registration + +Register `slonikPlugin` with direct options or let it fall back to `fastify.config.slonik`. + +```typescript +// Direct options (recommended) +await fastify.register(slonikPlugin, { db: { host: "localhost", ... } }); + +// Fallback to fastify-config (deprecated path — logs a warning) +// fastify.config.slonik must be set by @prefabs.tech/fastify-config +await fastify.register(slonikPlugin); +``` + +If neither source provides configuration the plugin throws: + +``` +Error: Missing slonik configuration. Did you forget to pass it to the slonik plugin? +``` + +### Feature 2 — Idempotent decorator registration + +The internal `fastifySlonik` plugin guards all `decorate` / `decorateRequest` calls with `hasDecorator` checks, so registering the plugin multiple times (e.g., in multiple scopes) will not throw a duplicate-decorator error. + +### Feature 3 — Connection verification on startup + +Immediately after pool creation the plugin opens a test connection: + +```typescript +// No code needed — happens automatically on registration. +// On success: fastify.log.info("✅ Connected to Postgres DB") +// On failure: fastify.log.error("🔴 Error happened while connecting to Postgres DB") +// + the error is rethrown +``` + +### Feature 4 — Auto-provisioned PostgreSQL extensions + +On every startup, before your route handlers run, the plugin runs: + +```sql +CREATE EXTENSION IF NOT EXISTS "citext"; +CREATE EXTENSION IF NOT EXISTS "unaccent"; +-- plus any extras from options.extensions +``` + +```typescript +await fastify.register(slonikPlugin, { + db: { ... }, + extensions: ["postgis", "uuid-ossp"], // merged with the defaults; duplicates removed +}); +``` + +### Feature 5 — migrationPlugin + +A standalone Fastify plugin that runs SQL file migrations on startup using `@prefabs.tech/postgres-migrations`. + +```typescript +import { migrationPlugin } from "@prefabs.tech/fastify-slonik"; + +// Register before the main plugin if you want migrations to run first +await fastify.register(migrationPlugin, { + db: { + host: "localhost", + port: 5432, + databaseName: "mydb", + username: "app", + password: "secret", + }, + migrations: { path: "db/migrations" }, // default: "migrations" +}); +``` + +### Feature 6–9 — Fastify instance and request decorators + +```typescript +fastify.get("/users", async (req, reply) => { + // Both the instance and the request have slonik + sql + const rows = await req.slonik.connect((conn) => + conn.any(req.sql`SELECT * FROM users`), + ); + + // Or use the instance-level decorator in plugins/hooks + const count = await fastify.slonik.query( + fastify.sql`SELECT COUNT(*) FROM users`, + ); + + return rows; +}); +``` + +### Feature 10 — `request.dbSchema` + +An empty string set on each request. Useful for multi-tenant applications where each request should query a different PostgreSQL schema. + +```typescript +fastify.addHook("onRequest", async (req) => { + req.dbSchema = getTenantSchema(req.headers["x-tenant-id"] as string); +}); + +fastify.get("/items", async (req) => { + const factory = new ItemSqlFactory(fastify.config, req.slonik, req.dbSchema); + // factory now queries `.items` +}); +``` + +### Feature 11–13 — Module augmentation + +The package extends three external interfaces so TypeScript knows about the added properties without any additional type imports. + +```typescript +import type { FastifyInstance, FastifyRequest } from "fastify"; + +// FastifyInstance.slonik / .sql are typed automatically +// FastifyRequest.slonik / .sql / .dbSchema are typed automatically +// ApiConfig.slonik is typed automatically + +// No extra imports needed in consuming code +const pool = fastify.slonik.pool; // DatabasePool +const tag = fastify.sql; // typeof sql +const schema = request.dbSchema; // string +``` + +### Feature 14 — `createClientConfiguration` factory + +Creates a `ClientConfigurationInput` with safe production defaults. You can override any field via `options.clientConfiguration`. + +```typescript +import { createDatabase } from "@prefabs.tech/fastify-slonik"; + +// createDatabase calls createClientConfiguration internally +const db = await createDatabase("postgres://localhost/mydb", { + maximumPoolSize: 5, // overrides default of 10 + connectionTimeout: 10_000, // overrides default of 5000 ms +}); +``` + +Default values applied when not overridden: + +| Setting | Default | +| --------------------------------- | ---------- | +| `captureStackTrace` | `false` | +| `connectionRetryLimit` | `3` | +| `connectionTimeout` | `5000 ms` | +| `idleInTransactionSessionTimeout` | `60000 ms` | +| `idleTimeout` | `5000 ms` | +| `maximumPoolSize` | `10` | +| `queryRetryLimit` | `5` | +| `statementTimeout` | `60000 ms` | +| `transactionRetryLimit` | `5` | + +### Feature 15 — Built-in interceptor chain + +Two interceptors are always active. You cannot disable them — if you need different behavior, do not use the main plugin and call `createDatabase` yourself. + +**fieldNameCaseConverter** converts every result row: + +``` +{ created_at: "2024-01-01", user_name: "alice" } +→ { createdAt: "2024-01-01", userName: "alice" } +``` + +**resultParser** validates rows when a query has a Zod schema: + +```typescript +import { sql } from "slonik"; +import { z } from "zod"; + +const userSchema = z.object({ id: z.number(), name: z.string() }); + +// Throws SchemaValidationError if the DB returns unexpected shape +const users = await conn.any(sql.type(userSchema)`SELECT id, name FROM users`); +``` + +### Feature 16 — Optional query-logging interceptor + +```typescript +await fastify.register(slonikPlugin, { + db: { ... }, + queryLogging: { enabled: true }, +}); +// Also requires ROARR_LOG=true in the environment +``` + +### Feature 17 — User interceptors merged + +```typescript +import type { Interceptor } from "slonik"; + +const myInterceptor: Interceptor = { + afterQueryExecution(context, query, result) { + metrics.recordQuery(query.sql, result.rowCount); + return result; + }, +}; + +await fastify.register(slonikPlugin, { + db: { ... }, + clientConfiguration: { + interceptors: [myInterceptor], // appended after built-ins + }, +}); +``` + +### Feature 18 — Extended type parsers + +`createBigintTypeParser` is always registered, converting PostgreSQL `bigint` / `int8` columns to JavaScript `number` instead of strings. + +### Feature 21 — `createBigintTypeParser` + +Exported for use outside the plugin (e.g., when calling `createDatabase` directly or building your own pool): + +```typescript +import { createBigintTypeParser } from "@prefabs.tech/fastify-slonik"; +import { createPool, createTypeParserPreset } from "slonik"; + +const pool = await createPool("postgres://...", { + typeParsers: [...createTypeParserPreset(), createBigintTypeParser()], +}); +``` + +### Feature 22 — `createDatabase` utility + +Creates a `Database` object from a connection string, without registering any Fastify decorators. Useful for scripts, tests, or service-layer code. + +```typescript +import { createDatabase } from "@prefabs.tech/fastify-slonik"; + +const db = await createDatabase("postgres://user:pw@localhost/mydb"); + +const rows = await db.connect((conn) => + conn.any(sql`SELECT id, name FROM users`), +); +``` + +### Features 23–30 — SQL fragment helpers + +These are the building blocks used by `DefaultSqlFactory`. You can use them directly when constructing custom queries. + +```typescript +import { + createFilterFragment, + createLimitFragment, + createSortFragment, + createTableFragment, + createTableIdentifier, + createWhereFragment, + createWhereIdFragment, + isValueExpression, +} from "@prefabs.tech/fastify-slonik"; +import { sql } from "slonik"; +import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; + +const tableId = createTableIdentifier("users", "public"); +const tableRef = createTableFragment("users", "public"); + +const filters: FilterInput = { key: "status", operator: "eq", value: "active" }; +const sort: SortInput[] = [{ key: "createdAt", direction: "DESC" }]; + +const whereClause = createWhereFragment(tableId, filters, []); +const sortClause = createSortFragment(tableId, sort); +const limitClause = createLimitFragment(20, 40); // LIMIT 20 OFFSET 40 + +const query = sql.unsafe` + SELECT * FROM ${tableRef} + ${whereClause} + ${sortClause} + ${limitClause} +`; +``` + +`isValueExpression` is useful when building dynamic INSERT/UPDATE helpers: + +```typescript +const safe = isValueExpression(someValue); // false for plain objects / functions +``` + +### Features 31–38 — Filter system + +Build structured, composable WHERE clauses without writing raw SQL strings. + +```typescript +import type { FilterInput } from "@prefabs.tech/fastify-slonik"; + +// Simple equality +const f1: FilterInput = { key: "status", operator: "eq", value: "active" }; + +// Case-insensitive contains +const f2: FilterInput = { + key: "name", + operator: "ct", + value: "alice", + insensitive: true, +}; + +// Negated IN +const f3: FilterInput = { + key: "role", + operator: "in", + value: "admin,moderator", + not: true, +}; + +// BETWEEN +const f4: FilterInput = { + key: "createdAt", + operator: "bt", + value: "2024-01-01,2024-12-31", +}; + +// PostGIS proximity (lat, lng, radius in meters) +const f5: FilterInput = { + key: "location", + operator: "dwithin", + value: "51.5074,-0.1278,1000", +}; + +// NULL check +const f6: FilterInput = { key: "deletedAt", operator: "eq", value: "null" }; + +// Recursive AND / OR +const composed: FilterInput = { + AND: [f1, { OR: [f2, f3] }], +}; +``` + +Pass any `FilterInput` to `BaseService` methods or `DefaultSqlFactory.getFindSql`: + +```typescript +const users = await userService.find(composed, [ + { key: "name", direction: "ASC" }, +]); +``` + +### Features 39–55 — DefaultSqlFactory + +Extend `DefaultSqlFactory` to get type-safe, parameterized SQL for a table. + +```typescript +import { DefaultSqlFactory } from "@prefabs.tech/fastify-slonik"; +import { z } from "zod"; + +const userSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string(), + deletedAt: z.string().nullable(), +}); + +class UserSqlFactory extends DefaultSqlFactory { + static override readonly TABLE = "users"; + + // Optional: use a Zod schema for automatic row validation + protected override _validationSchema = userSchema; + + // Optional: enable soft delete + protected override _softDeleteEnabled = true; +} +``` + +The factory generates all standard queries automatically: + +```typescript +const factory = new UserSqlFactory(fastify.config, fastify.slonik, "public"); + +// SELECT id, name FROM public.users ORDER BY id ASC +const allSql = factory.getAllSql(["id", "name"]); + +// SELECT COUNT(*) FROM public.users WHERE ... +const countSql = factory.getCountSql({ + key: "status", + operator: "eq", + value: "active", +}); + +// INSERT INTO public.users (name, email) VALUES ($1, $2) RETURNING * +const createSql = factory.getCreateSql({ + name: "Alice", + email: "alice@example.com", +}); + +// UPDATE public.users SET name = $1 WHERE id = $2 RETURNING * +const updateSql = factory.getUpdateSql(1, { name: "Alice B." }); + +// UPDATE public.users SET deleted_at = NOW() WHERE id = $1 RETURNING * +// (soft delete, because _softDeleteEnabled = true) +const deleteSql = factory.getDeleteSql(1); + +// DELETE FROM public.users WHERE id = $1 RETURNING * +// (force = true bypasses soft delete) +const hardDeleteSql = factory.getDeleteSql(1, true); +``` + +Override `getAdditionalFilterFragments` to inject permanent conditions into every read query: + +```typescript +import { sql } from "slonik"; + +class TenantSqlFactory extends DefaultSqlFactory { + static override readonly TABLE = "orders"; + + constructor( + config, + database, + schema, + private tenantId: number, + ) { + super(config, database, schema); + } + + protected override getAdditionalFilterFragments() { + return [sql.fragment`${this.tableIdentifier}.tenant_id = ${this.tenantId}`]; + } +} +``` + +### Features 56–67 — BaseService + +Extend `BaseService` to get a fully functional CRUD service backed by `DefaultSqlFactory`. + +```typescript +import { BaseService } from "@prefabs.tech/fastify-slonik"; +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { Database } from "@prefabs.tech/fastify-slonik"; + +type User = { id: number; name: string; email: string }; +type CreateUserDto = { name: string; email: string }; +type UpdateUserDto = Partial; + +class UserService extends BaseService { + // Override to use a custom factory + override get sqlFactoryClass() { + return UserSqlFactory; // UserSqlFactory from previous example + } +} + +const service = new UserService(fastify.config, fastify.slonik, "public"); + +// CRUD +const user = await service.create({ name: "Alice", email: "a@example.com" }); +const found = await service.findById(user!.id); +const updated = await service.update(user!.id, { name: "Alice B." }); +const deleted = await service.delete(user!.id); + +// Query +const activeUsers = await service.find({ + key: "status", + operator: "eq", + value: "active", +}); +const firstActive = await service.findOne({ + key: "status", + operator: "eq", + value: "active", +}); + +// Paginated list: { data, totalCount, filteredCount } +const page = await service.list(25, 0, { + key: "status", + operator: "eq", + value: "active", +}); + +// Count +const total = await service.count(); +``` + +### Feature 67 — Pre/post lifecycle hooks + +Override optional `pre` / `post` methods to transform data around DB calls. + +```typescript +class AuditedUserService extends BaseService< + User, + CreateUserDto, + UpdateUserDto +> { + override get sqlFactoryClass() { + return UserSqlFactory; + } + + // Called before create — return modified data or undefined to use original + protected override async preCreate( + data: CreateUserDto, + ): Promise { + return { ...data, name: data.name.trim() }; + } + + // Called after create — transform or enrich the result + protected override async postCreate(result: User): Promise { + await auditLog.record("user.created", result.id); + return result; + } + + // Called after list — example: mask sensitive fields + protected override async postList( + result: PaginatedList, + ): Promise> { + return { + ...result, + data: result.data.map((u) => ({ ...u, email: "***" })), + }; + } +} +``` + +### Feature 68 — `migrate` standalone utility + +Runs migrations outside of a Fastify application (e.g., in a CLI script): + +```typescript +import { migrate } from "@prefabs.tech/fastify-slonik"; // not directly exported from index; +// use migrationPlugin or call @prefabs.tech/postgres-migrations directly for CLI use. +``` + +The `migrate` function is used internally by `migrationPlugin`. For standalone CLI migration scripts, use `@prefabs.tech/postgres-migrations` directly or register `migrationPlugin` in a minimal Fastify app. + +### Feature 78 — `formatDate` + +Formats a `Date` to `"YYYY-MM-DD HH:mm:ss.SSS"` — the format PostgreSQL accepts for `timestamp without time zone` columns. + +```typescript +import { formatDate } from "@prefabs.tech/fastify-slonik"; + +const ts = formatDate(new Date()); // e.g. "2026-04-04 12:34:56.789" + +await conn.query(sql` + INSERT INTO events (name, occurred_at) + VALUES (${"user.login"}, ${ts}) +`); +``` + +--- + +## Use Cases + +### Use case 1 — Basic CRUD API route + +```typescript +import Fastify from "fastify"; +import slonikPlugin, { + BaseService, + DefaultSqlFactory, +} from "@prefabs.tech/fastify-slonik"; +import { z } from "zod"; + +const productSchema = z.object({ + id: z.number(), + name: z.string(), + price: z.number(), +}); +type Product = z.infer; + +class ProductFactory extends DefaultSqlFactory { + static override readonly TABLE = "products"; + protected override _validationSchema = productSchema; +} + +class ProductService extends BaseService< + Product, + Omit, + Partial> +> { + override get sqlFactoryClass() { + return ProductFactory; + } +} + +const fastify = Fastify(); +await fastify.register(slonikPlugin, { + db: { + host: "localhost", + databaseName: "shop", + username: "app", + password: "s3cr3t", + }, +}); + +fastify.get("/products", async (req) => { + const service = new ProductService(fastify.config, req.slonik); + const { data, totalCount } = await service.list(); + return { data, total: totalCount }; +}); + +fastify.post("/products", async (req, reply) => { + const service = new ProductService(fastify.config, req.slonik); + const product = await service.create(req.body as Omit); + return reply.status(201).send(product); +}); +``` + +### Use case 2 — Filtered and paginated list endpoint + +```typescript +import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; + +fastify.get("/products/search", async (req) => { + const { + q, + minPrice, + maxPrice, + page = 0, + limit = 25, + } = req.query as Record; + + const filters: FilterInput = { + AND: [ + ...(q + ? [ + { + key: "name", + operator: "ct" as const, + value: q, + insensitive: true, + }, + ] + : []), + ...(minPrice && maxPrice + ? [ + { + key: "price", + operator: "bt" as const, + value: `${minPrice},${maxPrice}`, + }, + ] + : []), + ], + }; + + const sort: SortInput[] = [{ key: "name", direction: "ASC" }]; + + const service = new ProductService(fastify.config, req.slonik); + return service.list( + Number(limit), + Number(page) * Number(limit), + filters, + sort, + ); +}); +``` + +### Use case 3 — Multi-tenant schema routing + +```typescript +fastify.addHook("onRequest", async (req) => { + const tenantId = req.headers["x-tenant-id"] as string; + req.dbSchema = tenantId ? `tenant_${tenantId}` : "public"; +}); + +fastify.get("/orders", async (req) => { + const service = new OrderService(fastify.config, req.slonik, req.dbSchema); + return service.list(); +}); +``` + +### Use case 4 — Soft-delete with forced hard delete + +```typescript +const userSchema = z.object({ + id: z.number(), + name: z.string(), + deletedAt: z.string().nullable(), +}); +type User = z.infer; + +class SoftUserFactory extends DefaultSqlFactory { + static override readonly TABLE = "users"; + protected override _validationSchema = userSchema; + protected override _softDeleteEnabled = true; +} + +class SoftUserService extends BaseService< + User, + Omit, + { name?: string } +> { + override get sqlFactoryClass() { + return SoftUserFactory; + } +} + +fastify.delete("/users/:id", async (req, reply) => { + const { id } = req.params as { id: string }; + const { force } = req.query as { force?: string }; + const service = new SoftUserService(fastify.config, req.slonik); + + // Soft delete (sets deleted_at). Pass force=true for a hard DELETE. + const result = await service.delete(Number(id), force === "true"); + return result ?? reply.status(404).send({ message: "Not found" }); +}); +``` + +### Use case 5 — Custom SQL with fragment helpers + +When `DefaultSqlFactory` does not cover your query, compose it directly: + +```typescript +import { + createTableFragment, + createTableIdentifier, + createWhereFragment, + createSortFragment, +} from "@prefabs.tech/fastify-slonik"; +import { sql } from "slonik"; +import { z } from "zod"; + +fastify.get("/reports/top-buyers", async (req) => { + const tableId = createTableIdentifier("orders", "public"); + const tableRef = createTableFragment("orders", "public"); + + const where = createWhereFragment(tableId, undefined, [ + sql.fragment`${tableId}.status = 'completed'`, + ]); + + const sort = createSortFragment(tableId, [ + { key: "totalSpent", direction: "DESC" }, + ]); + + const reportSchema = z.object({ userId: z.number(), totalSpent: z.number() }); + + const rows = await req.slonik.connect((conn) => + conn.any(sql.type(reportSchema)` + SELECT user_id, SUM(amount) AS total_spent + FROM ${tableRef} + ${where} + GROUP BY user_id + ${sort} + LIMIT 10 + `), + ); + + return rows; +}); +``` + +### Use case 6 — Running database migrations on startup + +```typescript +import Fastify from "fastify"; +import { migrationPlugin } from "@prefabs.tech/fastify-slonik"; +import slonikPlugin from "@prefabs.tech/fastify-slonik"; + +const fastify = Fastify(); + +const dbConfig = { + db: { + host: "localhost", + port: 5432, + databaseName: "mydb", + username: "app", + password: "s3cr3t", + }, + migrations: { path: "db/migrations" }, +}; + +// Run migrations before the main plugin so the schema is up to date +await fastify.register(migrationPlugin, dbConfig); +await fastify.register(slonikPlugin, dbConfig); + +await fastify.listen({ port: 3000 }); +``` + +### Use case 7 — Using `createDatabase` in a service / script + +```typescript +import { createDatabase } from "@prefabs.tech/fastify-slonik"; +import { sql } from "slonik"; + +// Standalone script — no Fastify instance needed +const db = await createDatabase("postgres://app:s3cr3t@localhost/mydb"); + +const rows = await db.connect((conn) => + conn.any(sql`SELECT id, name FROM users WHERE active = true`), +); + +console.log(rows); +await db.pool.end(); +``` diff --git a/packages/slonik/README.md b/packages/slonik/README.md index 57fc1185c..6d16a18b5 100644 --- a/packages/slonik/README.md +++ b/packages/slonik/README.md @@ -1,15 +1,28 @@ # @prefabs.tech/fastify-slonik -A [Fastify](https://github.com/fastify/fastify) plugin that provides an easy integration of slonik in a fastify API. +A [Fastify](https://github.com/fastify/fastify) plugin that provides an easy integration of slonik in a fastify API. The plugin is a thin wrapper around the [`fastify-slonik`](https://github.com/spa5k/fastify-slonik) plugin. The plugin also includes logic to run migrations via [`@prefabs.tech/postgres-migrations`](https://github.com/prefabs-tech/postgres-migrations#readme) which is forked from [`postgres-migrations`](https://github.com/thomwright/postgres-migrations#readme). +## Why this plugin? + +Connecting an application to a PostgreSQL database isn't just about initiating a connection pool; it requires structurally sound ways to handle schema migrations, enforce strict type safety across SQL payloads, and quickly bootstrap generic data services. We created this plugin to: + +- **Unify Connections**: Bootstraps the PostgreSQL connection pool across Fastify, providing type-safe decorators (`fastify.slonik` and `fastify.sql`) to easily execute queries heavily verified at compile time. +- **Automate Migrations**: Safely executes pending database migrations directly at application boot-up (via `@prefabs.tech/postgres-migrations`), avoiding complex external CLI requirements in automated deployments. +- **Provide Data-Layer Scaffolding**: It isn't just a basic database driver wrapper; it ships with standard `BaseService` and `DefaultSqlFactory` abstract classes that natively handle boilerplate CRUD tasks, geo-filtering (`dwithin`), and API sorting conventions. + +### Design Decisions: Why not Prisma or TypeORM? Why Slonik? + +1. **Performance and Predictability**: Traditional heavyweight ORMs like Prisma or TypeORM often generate unpredictable, wildly inefficient SQL queries at massive scale. Slonik forces you to write explicit, hyper-optimized raw SQL while flawlessly protecting you from SQL injection vulnerabilities through tagged template literals. +2. **First-Class TypeScript Types**: By using Slonik, we retain total architectural control over strict database interactions and execution planning while enjoying near-perfect TypeScript synchronization—without suffering the penalty of learning a restrictive proprietary query dialect. + ## Requirements -* [@prefabs.tech/fastify-config](../config/) -* [slonik](https://github.com/gajus/slonik) +- [@prefabs.tech/fastify-config](../config/) +- [slonik](https://github.com/gajus/slonik) ## Installation @@ -81,16 +94,16 @@ const start = async () => { const fastify = Fastify({ logger: config.logger, }); - + // Register fastify-config plugin await fastify.register(configPlugin, { config }); - + // Register fastify-slonik plugin await fastify.register(slonikPlugin, config.slonik); - + // Run database migrations await fastify.register(migrationPlugin, config.slonik); - + await fastify.listen({ port: config.port, host: "0.0.0.0", @@ -99,14 +112,17 @@ const start = async () => { start(); ``` + **Note: `migrationPlugin` should be registered after all the plugins.** ### Support for geo-filtering using `dwithin` + This package supports the filter for fetching the data from specific geographic area. This can return the data within specific area from the given co-ordinate point. Prerequisite: Ensure that PostGIS extension is enabled before using this filter. Reference: [Setting up PostGIS](https://postgis.net/documentation/getting_started/install_windows/enabling_postgis/) Example: + ``` { "key": "", "operator": "dwithin", "value": ",," } ``` @@ -115,13 +131,12 @@ Example: ### `db` - -| Attribute | Type | Description | -|------------|------|-------------| -| `database` | `string` | The name of the database to connect to. | -| `host` | `string` | The database's host. | +| Attribute | Type | Description | +| ---------- | -------- | -------------------------------------------- | +| `database` | `string` | The name of the database to connect to. | +| `host` | `string` | The database's host. | | `password` | `string` | The password for connecting to the database. | -| `port` | `number` | The database's port. | +| `port` | `number` | The database's port. | | `username` | `string` | The username for connecting to the database. | ### `migrations` @@ -131,6 +146,7 @@ Paths to the migrations files. You can specify 1 path per environment. Currently The path must be relative to node.js `process.cwd()`. ### Enabling query logging + To enable query logging, set `queryLogging.enabled` to `true` in the slonik config and set `ROARR_LOG=true` environment variable to ensure logs are printed to the console. ```typescript diff --git a/packages/slonik/eslint.config.js b/packages/slonik/eslint.config.js index 48a1291a4..7369a1f05 100644 --- a/packages/slonik/eslint.config.js +++ b/packages/slonik/eslint.config.js @@ -1,3 +1,22 @@ import fastifyConfig from "@prefabs.tech/eslint-config/fastify.js"; +import perfectionist from "eslint-plugin-perfectionist"; -export default fastifyConfig; +export default [ + ...fastifyConfig, + { + plugins: { + perfectionist, + }, + rules: { + // Disable conflicting default/import rules + "sort-imports": "off", + "import/order": "off", + + // Enable and spread Perfectionist's recommended rules + ...perfectionist.configs["recommended-alphabetical"].rules, + + // Add any Fastify-specific rule overrides here + "@typescript-eslint/no-explicit-any": "error", + }, + }, +]; diff --git a/packages/slonik/feature.md b/packages/slonik/feature.md new file mode 100644 index 000000000..455f32a63 --- /dev/null +++ b/packages/slonik/feature.md @@ -0,0 +1,2146 @@ +# @prefabs.tech/fastify-slonik Complete Features Reference + +A comprehensive Fastify plugin providing PostgreSQL database integration via Slonik with automatic migrations, type safety, and CRUD scaffolding. This document details every feature, capability, configuration option, and fix added by this plugin. + +**Version**: 0.93.5 +**License**: MIT + +--- + +## Table of Contents + +1. [Connection & Initialization](#1-connection--initialization) +2. [Core CRUD Operations](#2-core-crud-operations) +3. [Soft Delete Support](#3-soft-delete-support) +4. [Filtering System](#4-filtering-system) +5. [Sorting & Pagination](#5-sorting--pagination) +6. [Service Hooks](#6-service-hooks-pre--post-operations) +7. [Field Name Conversion](#7-field-name-conversion-camelcase--snake_case) +8. [Database Migrations](#8-database-migrations) +9. [SQL Factory & Generation](#9-sql-factory--generation) +10. [Data Transformation & Type Safety](#10-data-transformation--type-safety) +11. [Utilities & Helpers](#11-utilities--helpers) +12. [Special Behaviors & Optimizations](#12-special-behaviors--optimizations) +13. [Feature Matrix](#feature-matrix) +14. [Developer Workflow Example](#developer-workflow-example) +15. [Configuration Reference](#configuration-reference) +16. [Feature Checklist](#feature-checklist-for-developers) +17. [Performance Considerations](#performance-considerations) +18. [Automatic vs Manual](#whats-automatically-handled) + +--- + +## 1. CONNECTION & INITIALIZATION + +### 1.1 PostgreSQL Connection Pool Management + +**What it does**: Automatically establishes and maintains a connection pool to PostgreSQL with automatic retry logic. + +**Features**: + +- Connection retry logic: 3 automatic retries on connection failures +- Configurable pool size: Default max 10 connections +- Connection timeout: 5 seconds +- Query retry limit: 5 attempts +- Transaction retry limit: 5 attempts +- Statement timeout: 60 seconds +- Idle connection management: 5 second idle timeout, 60 second idle-in-transaction timeout + +**How developers use it**: + +```typescript +import slonikPlugin from "@prefabs.tech/fastify-slonik"; +import Fastify from "fastify"; + +const fastify = Fastify(); +await fastify.register(slonikPlugin, { + db: { + host: "localhost", + port: 5432, + username: "postgres", + password: "password", + databaseName: "myapp", + }, +}); + +// Connection pool automatically created with retry logic +``` + +**Internal config defaults**: + +- `captureStackTrace: false` +- `connectionRetryLimit: 3` +- `connectionTimeout: 5000` ms +- `idleInTransactionSessionTimeout: 60000` ms +- `idleTimeout: 5000` ms +- `maximumPoolSize: 10` +- `queryRetryLimit: 5` +- `statementTimeout: 60000` ms +- `transactionRetryLimit: 5` + +--- + +### 1.2 Fastify Decorators (Instance-Level) + +**What it does**: Provides database access through Fastify instance decorators available globally. + +**Decorators added to `fastify`**: + +- `fastify.slonik` - Database object with pool and query methods +- `fastify.sql` - Slonik SQL template tag for safe query building +- `fastify.config` - ApiConfig containing slonik configuration + +**Usage**: + +```typescript +fastify.get("/users", async (req, reply) => { + // Access via fastify instance + const users = await fastify.slonik.pool.any(fastify.sql`SELECT * FROM users`); + return users; +}); +``` + +--- + +### 1.3 Request-Level Database Access (Request Decorators) + +**What it does**: Automatically injects database access into every request, avoiding need to pass context. + +**Decorators added to `req`**: + +- `req.slonik` - Same database object as fastify.slonik +- `req.sql` - Slonik SQL template tag +- `req.dbSchema` - Schema name (customizable per request, defaults to empty string for public schema) + +**Implementation**: Registered via `onRequest` hook that fires on every request + +**Usage**: + +```typescript +fastify.post("/articles", async (req, reply) => { + // req.slonik and req.sql available automatically + const article = await req.slonik.pool.one( + req.sql` + INSERT INTO articles (title, content) + VALUES (${req.body.title}, ${req.body.content}) + RETURNING * + `, + ); + return article; +}); +``` + +--- + +### 1.4 Connection Testing on Startup + +**What it does**: Verifies database connectivity when plugin initializes. + +**How it works**: + +- Executes a test connection: `db.pool.connect(async () => {})` +- Throws error if connection fails, preventing app from starting with bad config +- Ensures database is reachable before accepting requests + +--- + +## 2. CORE CRUD OPERATIONS + +### 2.1 BaseService Foundation Class + +**What it does**: Abstract base class providing ready-made CRUD operations to avoid repetitive boilerplate. + +**How to use**: + +```typescript +import BaseService from "@prefabs.tech/fastify-slonik"; +import type { Database } from "@prefabs.tech/fastify-slonik"; + +class UserService extends BaseService { + static readonly TABLE = "users"; + + constructor(config: ApiConfig, database: Database) { + super(config, database); + } +} + +// In route handler +const userService = new UserService(config, fastify.slonik); +``` + +**Generic Parameters**: + +- `T` - Entity type (what's returned from database) +- `C` - Creation input type (what's passed to create) +- `U` - Update input type (what's passed to update) + +**Required static property**: + +- `TABLE: string` - Name of the database table + +**Optional static properties for defaults**: + +- `LIMIT_DEFAULT: number` - Default page size (default: 20) +- `LIMIT_MAX: number` - Maximum allowable page size (default: 50) +- `SORT_DIRECTION: "ASC" | "DESC"` - Default sort direction (default: "ASC") +- `SORT_KEY: string` - Default sort column (default: "id") + +--- + +### 2.2 Read Operations + +#### create(data: C): Promise + +Creates a new record and returns it. + +```typescript +const newUser = await userService.create({ + name: "John Doe", + email: "john@example.com", + age: 30, +}); +``` + +**Features**: + +- Automatically includes `preCreate` hook +- Automatically includes `postCreate` hook +- Returns undefined if creation fails +- Only includes fields with valid ValueExpression types (null, string, number, boolean, Date, Buffer, arrays) + +--- + +#### read(id: string | number): Promise + +Retrieves a single record by ID. + +```typescript +const user = await userService.findById(1); +// Returns user object or null if not found +``` + +**Aliases**: `findById()` is the actual method name + +--- + +#### find(filters?: FilterInput, sort?: SortInput[]): Promise + +Retrieves all records matching optional filters with optional sorting. + +```typescript +// Get all users +const allUsers = await userService.find(); + +// Get with filters +const admins = await userService.find({ + key: "role", + operator: "eq", + value: "admin", +}); + +// Get with sorting +const sorted = await userService.find(undefined, [ + { key: "createdAt", direction: "DESC" }, + { key: "name", direction: "ASC" }, +]); +``` + +**Features**: + +- No pagination applied (returns all matching records) +- Supports multiple filters combined with AND/OR +- Supports multi-column sorting +- Default sort: ORDER BY id ASC +- Excludes soft-deleted records if soft delete enabled + +--- + +#### findOne(filters?: FilterInput, sort?: SortInput[]): Promise + +Retrieves first matching record or null. + +```typescript +const admin = await userService.findOne({ + key: "role", + operator: "eq", + value: "admin", +}); +``` + +**Features**: + +- Uses LIMIT 1 for efficiency +- Returns null if no match found +- Respects sorting order + +--- + +#### all(fields: string[], sort?: SortInput[]): Promise> + +Retrieves all records with only specified fields (useful for dropdowns/selects). + +```typescript +// Get just id and name for a country selector +const countries = await userService.all(["id", "name"]); +// Returns: [{ id: 1, name: "USA" }, { id: 2, name: "Canada" }] +``` + +**Features**: + +- No pagination +- Field projection for smaller payloads +- Reduces data transfer + +--- + +#### count(filters?: FilterInput): Promise + +Returns total count of records matching optional filters. + +```typescript +const totalUsers = await userService.count(); +const activeUsers = await userService.count({ + key: "status", + operator: "eq", + value: "active", +}); +``` + +--- + +#### list(limit?: number, offset?: number, filters?: FilterInput, sort?: SortInput[]): Promise> + +Retrieves paginated results with count information. + +```typescript +const result = await userService.list( + 20, // limit (capped at maxLimit) + 0, // offset + { key: "status", operator: "eq", value: "active" }, // filters + [{ key: "createdAt", direction: "DESC" }], // sort +); + +// Returns: +// { +// totalCount: 1500, // Total records in table +// filteredCount: 234, // Records matching filter +// data: [...] // Paginated result set +// } +``` + +**Features**: + +- Enforces limit constraints (capped at maxLimit from config) +- Returns totalCount (for total record count) +- Returns filteredCount (for filtered subset count) +- Returns data array +- Respects filter and sort parameters + +--- + +### 2.3 Write Operations + +#### update(id: string | number, data: U): Promise + +Updates record by ID and returns updated record. + +```typescript +const updated = await userService.update(1, { + name: "Jane Doe", + status: "active", +}); +``` + +**Features**: + +- Triggers `preUpdate` hook for data transformation +- Triggers `postUpdate` hook for output transformation +- Returns updated record from database +- Only includes fields with valid ValueExpression types + +--- + +#### delete(id: string | number, force?: boolean): Promise + +Deletes record by ID (soft or hard delete depending on configuration). + +```typescript +// Soft delete (if enabled) +await userService.delete(1); + +// Force hard delete even if soft delete enabled +await userService.delete(1, true); +``` + +**Features**: + +- Respects soft delete configuration +- If soft delete enabled: sets deleted_at timestamp, keeps record in database +- If soft delete disabled or force=true: permanently removes record +- Triggers `preDelete` hook for validation +- Triggers `postDelete` hook for cleanup +- Returns deleted record or null + +--- + +## 3. SOFT DELETE SUPPORT + +### 3.1 Enabling Soft Deletes + +**What it does**: Marks records as deleted with timestamp instead of permanently removing them. + +**How to enable**: + +```typescript +class UserService extends BaseService { + static readonly TABLE = "users"; + + constructor(config: ApiConfig, database: Database) { + super(config, database); + this._softDeleteEnabled = true; // Enable soft deletes + } +} +``` + +**Prerequisite**: Database table must have `deleted_at` column (timestamp nullable). + +--- + +### 3.2 Automatic Soft Delete Filtering + +**What it does**: When soft delete is enabled, all queries automatically exclude deleted records. + +**Affected methods**: + +- `findById()` - Excludes soft-deleted records +- `find()` - Excludes soft-deleted records +- `findOne()` - Excludes soft-deleted records +- `all()` - Excludes soft-deleted records +- `count()` - Excludes soft-deleted records +- `list()` - Excludes soft-deleted records + +**Generated SQL**: + +- Adds `WHERE deleted_at IS NULL` filter automatically +- Developer doesn't need to remember to exclude deleted records +- All queries work as if deleted records don't exist + +--- + +### 3.3 Forcing Hard Delete + +**What it does**: Permanently removes record even if soft delete enabled. + +```typescript +// Soft delete (sets deleted_at) +await userService.delete(userId); + +// Force hard delete (permanent removal) +await userService.delete(userId, true); +``` + +--- + +## 4. FILTERING SYSTEM + +### 4.1 Overview + +**What it does**: Provides 11+ filter operators for building flexible WHERE clauses with AND/OR logic, all parameterized to prevent SQL injection. + +**Basic Filter Structure**: + +```typescript +type BaseFilterInput = { + key: string; // Field name to filter on + operator: operator; // One of 11 operators (see below) + value: string; // Filter value as string + not?: boolean; // Optional: negate the operator + insensitive?: boolean; // Optional: case-insensitive matching +}; + +type FilterInput = + | BaseFilterInput + | { AND: FilterInput[] } // Combine multiple filters with AND + | { OR: FilterInput[] }; // Combine multiple filters with OR +``` + +--- + +### 4.2 Equality Operator (eq) + +**Operator**: `"eq"` +**SQL Generated**: `=` or `IS NULL` +**Use**: Match exact values + +```typescript +// Simple equality +const admins = await userService.find({ + key: "role", + operator: "eq", + value: "admin", +}); + +// NULL check (special handling) +const deleted = await userService.find({ + key: "deletedAt", + operator: "eq", + value: "null", // Check IS NULL +}); + +// Negate (not equal) +const nonAdmins = await userService.find({ + key: "role", + operator: "eq", + value: "admin", + not: true, // Inverts to != +}); +``` + +--- + +### 4.3 Case-Insensitive Equality + +**Operator**: `"eq"` with `insensitive: true` +**SQL Generated**: `LOWER(unaccent(column)) = LOWER(unaccent(value))` +**Use**: Match values ignoring case and accents + +```typescript +const user = await userService.findOne({ + key: "email", + operator: "eq", + value: "JOHN@EXAMPLE.COM", + insensitive: true, // Matches "john@example.com" +}); + +// Works with accents too +const person = await userService.find({ + key: "name", + operator: "eq", + value: "jose", + insensitive: true, // Matches "José" (requires unaccent extension) +}); +``` + +**Prerequisite**: `insensitive` feature requires PostgreSQL `unaccent` extension (automatically created by plugin). + +--- + +### 4.4 String Pattern Operators + +#### Contains (ct) + +**Operator**: `"ct"` +**SQL Generated**: `ILIKE '%value%'` +**Use**: Match substring anywhere in field + +```typescript +const results = await userService.find({ + key: "name", + operator: "ct", + value: "john", +}); +// Matches: "John", "johnny", "John Doe", "benjamin" +``` + +--- + +#### Starts With (sw) + +**Operator**: `"sw"` +**SQL Generated**: `ILIKE 'value%'` +**Use**: Match string prefix + +```typescript +const results = await userService.find({ + key: "email", + operator: "sw", + value: "admin@", +}); +// Matches: "admin@example.com", "admin@company.org" +``` + +--- + +#### Ends With (ew) + +**Operator**: `"ew"` +**SQL Generated**: `ILIKE '%value'` +**Use**: Match string suffix + +```typescript +const results = await userService.find({ + key: "email", + operator: "ew", + value: "@example.com", +}); +// Matches: "john@example.com", "jane@example.com" +``` + +--- + +### 4.5 Comparison Operators + +#### Greater Than (gt) + +**Operator**: `"gt"` +**SQL Generated**: `> value` +**Use**: Numeric and date comparisons + +```typescript +const recentArticles = await userService.find({ + key: "createdAt", + operator: "gt", + value: "2024-01-01", +}); +``` + +--- + +#### Greater Than or Equal (gte) + +**Operator**: `"gte"` +**SQL Generated**: `>= value` + +```typescript +const adults = await userService.find({ + key: "age", + operator: "gte", + value: "18", +}); +``` + +--- + +#### Less Than (lt) + +**Operator**: `"lt"` +**SQL Generated**: `< value` + +```typescript +const affordable = await userService.find({ + key: "price", + operator: "lt", + value: "100", +}); +``` + +--- + +#### Less Than or Equal (lte) + +**Operator**: `"lte"` +**SQL Generated**: `<= value` + +```typescript +const limitedStock = await userService.find({ + key: "stock", + operator: "lte", + value: "10", +}); +``` + +--- + +### 4.6 IN Operator + +**Operator**: `"in"` +**SQL Generated**: `IN (value1, value2, ...)` +**Use**: Match any value from a list + +```typescript +// Find users with specific roles (comma-separated values) +const results = await userService.find({ + key: "role", + operator: "in", + value: "admin,moderator,editor", +}); + +// Negate to get NOT IN +const nonStaff = await userService.find({ + key: "status", + operator: "in", + value: "inactive,banned", + not: true, // Converts to NOT IN +}); +``` + +--- + +### 4.7 BETWEEN Operator + +**Operator**: `"bt"` +**SQL Generated**: `BETWEEN start AND end` +**Use**: Range filtering for numbers and dates + +```typescript +// Date range +const articles = await userService.find({ + key: "publishedAt", + operator: "bt", + value: "2024-01-01,2024-12-31", // comma-separated start,end +}); + +// Price range +const products = await userService.find({ + key: "price", + operator: "bt", + value: "10,100", +}); +``` + +--- + +### 4.8 Geographic Distance Operator (PostGIS) + +**Operator**: `"dwithin"` +**SQL Generated**: `ST_DWithin(geometry_column, ST_Point(long, lat), radius)` +**Use**: Find records within geographic radius + +```typescript +const nearbyStores = await storeService.find({ + key: "location", // Geographic point column + operator: "dwithin", + value: "40.7128,-74.0060,5000", // latitude,longitude,radius_in_meters +}); +// Returns stores within 5km of NYC coordinates +``` + +**Prerequisite**: Requires PostgreSQL PostGIS extension (can be enabled in config). + +**Format**: `"latitude,longitude,radius_in_meters"` + +--- + +### 4.9 Negation + +**Feature**: All operators can be inverted with `not` flag + +```typescript +// NOT equal +const admins = await userService.find({ + key: "role", + operator: "eq", + value: "admin", + not: true, // NOT role = 'admin' +}); + +// NOT contains +const excluded = await userService.find({ + key: "name", + operator: "ct", + value: "spam", + not: true, // Names NOT containing 'spam' +}); + +// NOT in +const active = await userService.find({ + key: "status", + operator: "in", + value: "inactive,banned", + not: true, // NOT IN clause +}); +``` + +--- + +### 4.10 Combining Filters with AND/OR + +#### AND Logic + +```typescript +// Multiple conditions all must be true +const activeAdmins = await userService.find({ + AND: [ + { key: "role", operator: "eq", value: "admin" }, + { key: "status", operator: "eq", value: "active" }, + ], +}); +// WHERE role = 'admin' AND status = 'active' +``` + +#### OR Logic + +```typescript +// Any condition can be true +const important = await userService.find({ + OR: [ + { key: "priority", operator: "eq", value: "high" }, + { key: "priority", operator: "eq", value: "urgent" }, + ], +}); +// WHERE priority = 'high' OR priority = 'urgent' +``` + +#### Complex Nested Filters + +```typescript +// (role = admin AND status = active) OR (role = moderator AND status = active) +const active = await userService.find({ + OR: [ + { + AND: [ + { key: "role", operator: "eq", value: "admin" }, + { key: "status", operator: "eq", value: "active" }, + ], + }, + { + AND: [ + { key: "role", operator: "eq", value: "moderator" }, + { key: "status", operator: "eq", value: "active" }, + ], + }, + ], +}); +``` + +--- + +### 4.11 Accent-Insensitive Filtering + +**Feature**: Remove accents from characters during comparison + +```typescript +// Finds "José", "jose", "Jóse", "JOSÉ" all the same +const user = await userService.find({ + key: "name", + operator: "ct", + value: "jose", + insensitive: true, // Enables unaccent +}); +``` + +**How it works**: + +- Wraps filter value in PostgreSQL `unaccent()` function +- Wraps database column in `unaccent()` function +- Case-insensitive comparison via `LOWER()` +- Requires PostgreSQL `unaccent` extension + +**Prerequisite**: Plugin automatically creates `unaccent` extension on startup. + +--- + +### 4.12 Filtering Auto-Conversion (camelCase to snake_case) + +**What it does**: Automatically converts JavaScript field names to database column names. + +```typescript +// API input uses camelCase +const users = await userService.find({ + key: "createdAt", // camelCase in API + operator: "gt", + value: "2024-01-01", +}); + +// Plugin converts to created_at automatically for SQL +// SELECT * FROM users WHERE created_at > '2024-01-01' +``` + +--- + +## 5. SORTING & PAGINATION + +### 5.1 Multi-Column Sorting + +**What it does**: Sort results by one or more columns in ascending or descending order. + +```typescript +// Sort by createdAt DESC, then name ASC +const users = await userService.find( + undefined, // filters + [ + { key: "createdAt", direction: "DESC" }, + { key: "name", direction: "ASC" }, + ], +); +// Generated SQL: ORDER BY created_at DESC, name ASC +``` + +**SortInput Type**: + +```typescript +type SortInput = { + key: string; // Column name + direction: "ASC" | "DESC"; // Sort direction + insensitive?: boolean; // Case-insensitive sorting +}; +``` + +--- + +### 5.2 Default Sorting + +**What it does**: If no sort specified, automatically sorts by ID ascending. + +```typescript +const users = await userService.find(); // Defaults to ORDER BY id ASC +``` + +**Customizable defaults**: + +```typescript +class CustomService extends DefaultSqlFactory { + static readonly SORT_KEY = "createdAt"; + static readonly SORT_DIRECTION = "DESC"; + // Now defaults to ORDER BY created_at DESC +} +``` + +--- + +### 5.3 Case-Insensitive Sorting + +**What it does**: Sort strings ignoring case differences. + +```typescript +const users = await userService.find(undefined, [ + { key: "name", direction: "ASC", insensitive: true }, +]); +// Uses: LOWER(name) ASC +``` + +--- + +### 5.4 Offset-Based Pagination + +**What it does**: Paginate through results using limit and offset. + +```typescript +// Page 1: limit=20, offset=0 +const page1 = await userService.list(20, 0); + +// Page 2: limit=20, offset=20 +const page2 = await userService.list(20, 20); + +// Page 3: limit=20, offset=40 +const page3 = await userService.list(20, 40); +``` + +**Generated SQL**: `LIMIT 20 OFFSET 0` + +--- + +### 5.5 Pagination Limit Enforcement + +**What it does**: Enforce maximum record limits to prevent resource exhaustion. + +```typescript +// Request 100 records, but only get max 50 (default maxLimit) +const result = await userService.list(100, 0); +// Returns only 50 records, not 100 +``` + +**Configuration**: + +```typescript +const config = { + slonik: { + pagination: { + defaultLimit: 20, // If limit not specified + maxLimit: 50, // Hard cap on limit + }, + }, +}; +``` + +**Behavior**: + +- If limit not provided: uses defaultLimit (20) +- If limit exceeds maxLimit: capped at maxLimit +- Prevents developers from requesting massive datasets accidentally + +--- + +### 5.6 Paginated List with Counts + +**What it does**: Get paginated results with comprehensive count information. + +```typescript +const result = await userService.list( + 10, // limit + 0, // offset + { key: "status", operator: "eq", value: "active" }, + [{ key: "createdAt", direction: "DESC" }], +); + +// Returns object with: +console.log(result.totalCount); // Total records in table +console.log(result.filteredCount); // Records matching filter +console.log(result.data); // Paginated result set (10 records) +``` + +**Use Cases**: + +- UI pagination: Show "Showing 1-10 of 234 active users" +- Understand dataset size: Know if need to show next page button +- Analytics: Count filtered vs total records + +--- + +## 6. SERVICE HOOKS (PRE & POST OPERATIONS) + +### 6.1 Pre-Operation Hooks + +**What it does**: Transform or validate input before database operations execute. + +**Available Pre-Hooks**: + +#### preCreate(data: C): Promise + +Called before INSERT operation. + +```typescript +class UserService extends BaseService { + static readonly TABLE = "users"; + + protected async preCreate(data: CreateUserInput) { + // Hash password before creating + return { + ...data, + password: await bcrypt.hash(data.password, 10), + email: data.email.toLowerCase(), + }; + } +} +``` + +**Use Cases**: + +- Password hashing +- Email normalization +- Default value assignment +- Validation (throw error to reject) +- Data enrichment + +--- + +#### preUpdate(data: U): Promise + +Called before UPDATE operation. + +```typescript +protected async preUpdate(data: UpdateUserInput) { + return { + ...data, + email: data.email?.toLowerCase(), + updatedAt: new Date(), + }; +} +``` + +--- + +#### preDelete(id: string | number): Promise + +Called before DELETE operation (no return value). + +```typescript +protected async preDelete(id: number) { + const user = await this.findById(id); + + // Validation: prevent deleting last admin + if (user?.role === "admin" && await this.isLastAdmin()) { + throw new Error("Cannot delete the last admin user"); + } +} +``` + +**Use Cases**: + +- Validation before destructive operations +- Preventing deletion of critical records +- Checking permissions +- Cleanup of related data + +--- + +#### preFind(), preFindById(id), preFindOne(): Promise + +Called before SELECT operations (no parameters or return value). + +```typescript +protected async preFindById(id: number) { + // Log access for audit trail + await auditLog.record("user_accessed", { userId: id }); +} +``` + +--- + +#### preAll(), preCount(), preList(): Promise + +Additional pre-hooks for other operations. + +--- + +### 6.2 Post-Operation Hooks + +**What it does**: Transform or enrich output after database operations complete. + +**Available Post-Hooks**: + +#### postFindById(result: T): Promise + +Called after SELECT by ID, can modify returned record. + +```typescript +protected async postFindById(user: User) { + // Strip sensitive fields from returned user + const { password, twoFactorSecret, ...safe } = user; + return safe; +} +``` + +**Use Cases**: + +- Remove sensitive data (passwords, secrets) +- Enrich with computed properties +- Format dates or other data types +- Add flags based on current user + +--- + +#### postFind(result: readonly T[]): Promise + +Called after SELECT multiple records. + +```typescript +protected async postFind(users: readonly User[]) { + // Add computed isAdmin flag + return users.map(user => ({ + ...user, + isAdmin: user.role === "admin", + displayName: `${user.firstName} ${user.lastName}`, + })); +} +``` + +--- + +#### postList(result: PaginatedList): Promise> + +Called after paginated SELECT, can modify data array, counts, or entire result. + +```typescript +protected async postList(result: PaginatedList) { + return { + ...result, + data: result.data.map(user => ({ + ...user, + hasActiveSessions: await this.checkActiveSessions(user.id), + })), + }; +} +``` + +--- + +#### postCreate(result: T): Promise + +Called after INSERT, can modify created record before returning to caller. + +```typescript +protected async postCreate(user: User) { + // Send welcome email + await emailService.sendWelcomeEmail(user.email); + + // Log creation + await auditLog.record("user_created", { userId: user.id }); + + return user; +} +``` + +--- + +#### postUpdate(result: T): Promise + +Called after UPDATE. + +```typescript +protected async postUpdate(user: User) { + // Invalidate cache + await cache.invalidateUser(user.id); + + return user; +} +``` + +--- + +#### postDelete(result: T): Promise + +Called after DELETE (soft or hard). + +```typescript +protected async postDelete(user: User) { + // Clean up related resources + await sessionService.invalidateUserSessions(user.id); + await fileService.deleteUserFiles(user.id); + + return user; +} +``` + +--- + +#### postAll(result: Partial): Promise> + +Called after SELECT with field projection. + +--- + +#### postCount(result: number): Promise + +Called after COUNT operation. + +--- + +### 6.3 Hook Execution Order + +**Flow for find(filters) with hooks**: + +1. `preFind()` executes +2. SQL query built with filters +3. SQL query executed against database +4. Field names converted (snake_case → camelCase) +5. Results validated against schema if provided +6. `postFind(results)` executes on transformed results +7. Final results returned to caller + +**Hook Error Handling**: + +- If pre-hook throws: operation aborted, error propagated +- If post-hook throws: database operation already committed, error propagated to caller + +--- + +## 7. FIELD NAME CONVERSION (camelCase ↔ snake_case) + +### 7.1 Automatic Output Conversion + +**What it does**: Database columns (snake_case) automatically converted to JavaScript properties (camelCase). + +**Implementation**: Applied by `fieldNameCaseConverter` interceptor on every query result. + +```typescript +// Database table columns: user_id, first_name, created_at, updated_at +const user = await fastify.slonik.pool.one( + fastify.sql`SELECT user_id, first_name, created_at FROM users WHERE id = ${1}`, +); + +// Result automatically has camelCase keys: +console.log(user.userId); // ✓ Works (from user_id) +console.log(user.firstName); // ✓ Works (from first_name) +console.log(user.createdAt); // ✓ Works (from created_at) +console.log(user.user_id); // ✗ Undefined +``` + +**How it works**: Uses `humps.camelizeKeys()` on every row returned from database. + +--- + +### 7.2 Automatic Input Conversion + +**What it does**: JavaScript objects automatically converted to snake_case for database operations. + +```typescript +// create() with camelCase input +const newUser = await userService.create({ + firstName: "John", + lastName: "Doe", + emailAddress: "john@example.com", +}); + +// Automatically converted for INSERT: +// INSERT INTO users (first_name, last_name, email_address) +// VALUES ('John', 'Doe', 'john@example.com') +``` + +**How it works**: Uses `humps.decamelize()` when building INSERT/UPDATE SQL. + +--- + +### 7.3 Filter Field Conversion + +**What it does**: Filter keys automatically converted from camelCase to snake_case. + +```typescript +const users = await userService.find({ + key: "createdAt", // camelCase in API + operator: "gt", + value: "2024-01-01", +}); + +// Automatically becomes: WHERE created_at > '2024-01-01' +``` + +--- + +### 7.4 Sort Field Conversion + +**What it does**: Sort column names automatically converted from camelCase to snake_case. + +```typescript +const users = await userService.find(undefined, [ + { key: "createdAt", direction: "DESC" }, // camelCase + { key: "firstName", direction: "ASC" }, // camelCase +]); + +// Automatically becomes: ORDER BY created_at DESC, first_name ASC +``` + +--- + +## 8. DATABASE MIGRATIONS + +### 8.1 Auto-Running Migrations on Startup + +**What it does**: Automatically executes pending migrations when application boots. + +**Setup**: + +```typescript +import slonikPlugin, { migrationPlugin } from "@prefabs.tech/fastify-slonik"; + +const fastify = Fastify(); +await fastify.register(configPlugin, { config }); +await fastify.register(slonikPlugin, config.slonik); +await fastify.register(migrationPlugin, config.slonik); // Runs migrations + +// Note: migrationPlugin must be registered AFTER slonikPlugin +``` + +**How it works**: + +1. Reads migration directory specified in config +2. Checks which migrations have been run (tracks in \_migrations table) +3. Executes any pending migrations in order +4. Updates \_migrations table on successful execution +5. Throws error if migration fails, preventing app from starting + +**Benefits**: + +- No external CLI tools needed +- Database always in correct state when app starts +- Automated deployments don't need separate migration step + +--- + +### 8.2 Environment-Specific Migration Paths + +**What it does**: Use different migration directories for development and production. + +```typescript +const config = { + slonik: { + db: { + /* ... */ + }, + migrations: { + development: "migrations", // Dev migrations directory + production: "build/migrations", // Compiled migrations for prod + }, + }, +}; +``` + +**Use Cases**: + +- Development: Reference source migrations directly +- Production: Use compiled/built migrations +- Different sets of seed data per environment + +--- + +### 8.3 PostgreSQL Extensions Setup + +**What it does**: Automatically creates required PostgreSQL extensions on startup. + +**Default extensions** created: + +- `citext` - Case-insensitive text type +- `unaccent` - Text search support for accent-insensitive matching + +**Configuration**: + +```typescript +const config = { + slonik: { + db: { + /* ... */ + }, + extensions: ["citext", "unaccent", "postgis", "uuid-ossp"], + }, +}; +``` + +**Available Extensions**: + +- `citext` - For case-insensitive text fields +- `unaccent` - For accent-insensitive searching +- `postgis` - For geographic data (latitude/longitude) +- `uuid-ossp` - For UUID generation +- Custom extensions as needed + +**How it works**: + +1. Extensions specified in config are merged with defaults +2. Duplicates removed +3. Each extension created with `CREATE EXTENSION IF NOT EXISTS` +4. Runs on every startup (idempotent, safe to run multiple times) + +**Benefits**: + +- No manual setup needed +- Extensions available for migrations to use +- Enables use of special data types (PostGIS, citext, etc.) + +--- + +## 9. SQL FACTORY & GENERATION + +### 9.1 DefaultSqlFactory Overview + +**What it does**: Abstract factory class that generates parameterized SQL queries. + +**Used by**: BaseService internally (developers don't typically use directly). + +**How BaseService uses it**: + +```typescript +class UserService extends BaseService { + static readonly TABLE = "users"; + static readonly LIMIT_DEFAULT = 30; // Override default + static readonly LIMIT_MAX = 100; // Override max + + get sqlFactoryClass() { + return DefaultSqlFactory; // Can override for custom factory + } +} +``` + +--- + +### 9.2 SQL Generation Methods + +#### SQL for Fetching Records + +- `getListSql(limit, offset, filters, sort)` - LIMIT/OFFSET pagination +- `getFindSql(filters, sort)` - No pagination +- `getFindOneSql(filters, sort)` - LIMIT 1 +- `getFindByIdSql(id)` - Simple SELECT by primary key +- `getAllSql(fields, sort)` - Fetch specific fields only +- `getCountSql(filters)` - COUNT(\*) + +--- + +#### SQL for Modifying Records + +- `getCreateSql(data)` - INSERT with RETURNING \* +- `getUpdateSql(id, data)` - UPDATE with RETURNING \* +- `getDeleteSql(id, force)` - DELETE or UPDATE (soft delete) + +--- + +### 9.3 Static Configuration Properties + +These can be overridden in subclass: + +```typescript +class ProductSqlFactory extends DefaultSqlFactory { + static readonly TABLE = "products"; // Required + static readonly LIMIT_DEFAULT = 50; // Default page size + static readonly LIMIT_MAX = 200; // Max page size + static readonly SORT_DIRECTION = "DESC"; // ASC or DESC + static readonly SORT_KEY = "createdAt"; // Default sort column +} +``` + +--- + +### 9.4 Schema Support + +**What it does**: Query different database schemas (not public schema). + +```typescript +// Query from analytics schema instead of public +class ReportService extends BaseService< + Report, + CreateReportInput, + UpdateReportInput +> { + static readonly TABLE = "reports"; + + constructor(config: ApiConfig, database: Database) { + super(config, database, "analytics"); // Specify schema as 3rd param + } +} + +// Generates: SELECT * FROM analytics.reports +``` + +--- + +## 10. DATA TRANSFORMATION & TYPE SAFETY + +### 10.1 Type-Safe Query Execution with Zod + +**What it does**: Validates query results against Zod schemas at runtime. + +```typescript +import { z } from "zod"; + +const userSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string().email(), + age: z.number().optional(), +}); + +// Results validated and typed as User +const user = await fastify.slonik.pool.one( + fastify.sql.type(userSchema)`SELECT * FROM users WHERE id = ${1}`, +); + +// TypeScript knows user.id, user.name, user.email, user.age +// Runtime validation ensures data matches schema +``` + +**How it works**: + +1. `sql.type(schema)` sets schema on queryContext +2. Query executes +3. `resultParser` interceptor validates each row +4. Throws `SchemaValidationError` if row doesn't match schema +5. Returns typed result + +**Benefits**: + +- Type safety at compile time (TypeScript) +- Data validation at runtime (Zod) +- Catches corrupted data before reaching application +- Self-documenting result types + +--- + +### 10.2 Date Formatting + +**What it does**: Convert JavaScript dates to PostgreSQL-compatible format. + +```typescript +import { formatDate } from "@prefabs.tech/fastify-slonik"; + +const now = new Date(); +const formatted = formatDate(now); +// Returns: "2024-03-15 14:30:45.123" + +const user = await userService.create({ + name: "John", + createdAt: formatted, +}); +``` + +**Format**: `YYYY-MM-DD HH:mm:ss.SSS` (PostgreSQL timestamp format without timezone) + +--- + +### 10.3 BigInt Type Parsing + +**What it does**: Handle PostgreSQL `int8` (64-bit integer) values in JavaScript. + +```typescript +// Configured automatically in plugin +const stats = await fastify.slonik.pool.one( + fastify.sql.type(z.object({ views: z.number() }))` + SELECT views FROM analytics WHERE id = ${1} + `, +); + +console.log(typeof stats.views); // "number" +console.log(stats.views); // Parsed as regular JavaScript number +``` + +**How it works**: + +- Creates custom Slonik type parser for `int8` +- Converts PostgreSQL int64 values to JavaScript Number +- Parser: `Number.parseInt(value, 10)` + +**Note**: `@todo` comment in code suggests future support for native BigInt when value > Number.MAX_SAFE_INTEGER. + +--- + +## 11. UTILITIES & HELPERS + +### 11.1 Direct Database Connection + +**What it does**: Manually create database connection pool (useful for CLI scripts, tests). + +```typescript +import { createDatabase } from "@prefabs.tech/fastify-slonik"; + +const db = await createDatabase("postgresql://user:pass@localhost/myapp", { + connectionRetryLimit: 5, + maximumPoolSize: 20, +}); + +const users = await db.pool.any(db.sql`SELECT * FROM users`); +``` + +--- + +### 11.2 Direct SQL Execution + +**What it does**: Execute raw parameterized queries outside of BaseService. + +**Via request** (inside route handler): + +```typescript +fastify.get("/users/:id", async (req, reply) => { + const user = await req.slonik.pool.one( + req.sql`SELECT * FROM users WHERE id = ${req.params.id}`, + ); + return user; +}); +``` + +**Via fastify instance**: + +```typescript +const result = await fastify.slonik.query( + fastify.sql`UPDATE users SET status = ${"active"} WHERE id = ${1}`, +); +``` + +**Via database object**: + +```typescript +const users = await db.pool.any(db.sql`SELECT * FROM users`); +``` + +--- + +### 11.3 Connection Routine Execution + +**What it does**: Execute code within a single database connection context. + +```typescript +const user = await db.connect(async (connection) => { + const created = await connection.query( + sql`INSERT INTO users (name) VALUES ${"John"} RETURNING *`, + ); + + const preferences = await connection.query( + sql`INSERT INTO preferences (user_id) VALUES ${created.id} RETURNING *`, + ); + + return { created, preferences }; +}); +``` + +--- + +### 11.4 Exported Type Definitions + +Available for TypeScript: + +```typescript +// Core types +export type PaginatedList = { + totalCount: number; + filteredCount: number; + data: readonly T[]; +}; + +export interface Service { + create(data: C): Promise; + find(filters?: FilterInput, sort?: SortInput[]): Promise; + findById(id: string | number): Promise; + findOne(filters?: FilterInput, sort?: SortInput[]): Promise; + list( + limit?: number, + offset?: number, + filters?: FilterInput, + sort?: SortInput[], + ): Promise>; + update(id: string | number, data: U): Promise; + delete(id: string | number, force?: boolean): Promise; + all(fields: string[], sort?: SortInput[]): Promise>; + count(filters?: FilterInput): Promise; +} + +export type FilterInput = + | BaseFilterInput + | { AND: FilterInput[] } + | { OR: FilterInput[] }; +export type SortInput = { + key: string; + direction: "ASC" | "DESC"; + insensitive?: boolean; +}; +export type Database = { + pool: DatabasePool; + query: QueryFunction; + connect: ConnectionRoutine; +}; +``` + +--- + +## 12. SPECIAL BEHAVIORS & OPTIMIZATIONS + +### 12.1 SQL Injection Prevention + +**What it does**: Prevents SQL injection through parameterized queries. + +**Mechanism**: + +- All queries use Slonik's tagged template literals +- Values automatically parameterized and bound +- Column/table identifiers safely escaped via `sql.identifier()` +- Even filter values safely bound + +```typescript +// Safe: value parameterized +await userService.find({ + key: "email", + operator: "eq", + value: "admin'; DROP TABLE users; --", // Safely escaped, not executable +}); + +// Safe: column name identifier +const column = "name"; // Even if from user input +const sql = `SELECT * FROM users WHERE ${sql.identifier([column])} = $1`; +``` + +--- + +### 12.2 Connection Pool & Retry Logic + +**What it does**: Automatic retries and connection management for resilience. + +**Retry Configuration** (all automatic, no code needed): + +- Connection retry limit: 3 attempts +- Query retry limit: 5 attempts +- Transaction retry limit: 5 attempts + +**Timeouts**: + +- Connection timeout: 5 seconds +- Idle timeout: 5 seconds +- Idle-in-transaction timeout: 60 seconds +- Statement timeout: 60 seconds + +**Benefits**: + +- Transient failures don't crash app +- Long-running queries don't hang indefinitely +- Idle connections cleaned up automatically + +--- + +### 12.3 Value Type Validation + +**What it does**: Ensures only safe types included in SQL queries. + +```typescript +const isValueExpression = (value) => { + // Accepts: null, string, number, boolean, Date, Buffer, arrays of ValueExpressions + return ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + value instanceof Date || + value instanceof Buffer || + Array.isArray(value) + ); +}; + +// In create/update: Only includes fields with valid ValueExpression values +// Invalid types (functions, objects, undefined) automatically filtered out +``` + +--- + +### 12.4 Query Logging + +**What it does**: Optional SQL query logging for debugging and monitoring. + +**Enable**: + +```typescript +const config = { + slonik: { + db: { + /* ... */ + }, + queryLogging: { + enabled: true, + }, + }, +}; + +// Start app with: ROARR_LOG=true npm run dev +``` + +**Output**: + +``` +SQL queries logged directly to console via Roarr logger +All parameterized values included +Execution time shown +``` + +**Limitation**: Roarr logger is independent from Fastify Pino logger; logs to console only (doesn't support file output natively). + +--- + +### 12.5 Result Validation & Schema Enforcement + +**What it does**: Optional runtime validation of query results against Zod schemas. + +```typescript +const schema = z.object({ + id: z.number(), + email: z.string().email(), +}); + +const user = await fastify.slonik.pool.one( + fastify.sql.type(schema)`SELECT * FROM users WHERE id = ${1}`, +); + +// If row doesn't match schema: +// - Throws SchemaValidationError +// - Includes field-by-field validation errors +// - Prevents corrupt/unexpected data from propagating +``` + +--- + +### 12.6 Configuration Validation + +**What it does**: Validates configuration on plugin registration. + +```typescript +// If required fields missing, plugin throws before app starts +await fastify.register(slonikPlugin, { + db: { + host: process.env.DB_HOST, // Must be provided + port: 5432, + username: process.env.DB_USER, + password: process.env.DB_PASSWORD, + databaseName: process.env.DB_NAME, + }, +}); +``` + +--- + +### 12.7 Lazy SqlFactory Initialization + +**What it does**: SqlFactory instances created only when needed. + +**Benefit**: Reduces memory overhead if service methods not called, improves startup time. + +--- + +## FEATURE MATRIX + +| Feature | Category | Direct Use | Indirect Use | Configurable | +| -------------------------- | ----------- | ------------------------ | ----------------------- | --------------------------- | +| PostgreSQL Connection Pool | Core | Via req.slonik | Automatic | Yes | +| Connection Retries (3x) | Core | N/A | Automatic | Yes | +| Fastify Decorators | Core | Via req.slonik, req.sql | Every request | N/A | +| CRUD via BaseService | Core | Method calls | Service base | Via static properties | +| Soft Delete Support | Data | delete(id, force) | Automatic filtering | Via \_softDeleteEnabled | +| 11+ Filter Operators | Query | find(filters) | All find methods | N/A (built-in) | +| AND/OR Composition | Query | find({ AND: [...] }) | All find methods | N/A | +| Case-Insensitive Search | Query | insensitive: true | Filter processing | Requires unaccent extension | +| Geographic Filtering | Query | operator: "dwithin" | find() with geo data | Requires PostGIS | +| Multi-Column Sorting | Query | sort parameter | find(), list() | Via SortInput | +| Offset Pagination | Pagination | list(limit, offset) | Built-in | Via config | +| Limit Enforcement | Pagination | N/A | Automatic capping | Via config.pagination | +| Paginated Counts | Pagination | list() return value | Pagination feature | N/A | +| Pre-Operation Hooks | Extension | protected preCreate() | Service methods | Override method | +| Post-Operation Hooks | Extension | protected postFindById() | Service methods | Override method | +| camelCase ↔ snake_case | Convenience | Automatic | All queries/results | Via interceptor | +| Auto Migrations | Startup | migrationPlugin | App boot | Configurable path | +| Extension Management | Startup | N/A | Auto-created | Via config.extensions | +| Type-Safe Queries (Zod) | Type Safety | sql.type(schema) | Query validation | Via schema parameter | +| Date Formatting | Conversion | formatDate(date) | Manual use | Via utility | +| BigInt Parsing | Type Safety | N/A | Automatic | Via type parser | +| Query Logging | Debugging | N/A | Via interceptor | Via queryLogging.enabled | +| SQL Injection Prevention | Security | N/A | All queries (automatic) | N/A | +| Value Type Validation | Security | N/A | create/update filter | N/A | + +--- + +## DEVELOPER WORKFLOW EXAMPLE + +Here's how these features work together in a typical API endpoint: + +```typescript +// 1. Define service with hooks +class ProductService extends BaseService< + Product, + CreateProductInput, + UpdateProductInput +> { + static readonly TABLE = "products"; + static readonly LIMIT_DEFAULT = 20; + static readonly LIMIT_MAX = 100; + + protected async preCreate(data: CreateProductInput) { + // Normalize data + return { + ...data, + name: data.name.trim(), + slug: data.name.toLowerCase().replace(/\s+/g, "-"), + }; + } + + protected async postList(result) { + // Enrich results + return { + ...result, + data: result.data.map((p) => ({ + ...p, + inStock: p.quantity > 0, + })), + }; + } +} + +// 2. Use in route handler (camelCase input converted to snake_case automatically) +fastify.get("/products", async (req, reply) => { + const result = await new ProductService(config, req.slonik).list( + 20, // limit + 0, // offset + { + AND: [ + // insensitive: true enables unaccent + lowercase + { key: "name", operator: "ct", value: "laptop", insensitive: true }, + // Comparison operators work too + { key: "price", operator: "bt", value: "100,2000" }, + // NOT support + { key: "status", operator: "eq", value: "discontinued", not: true }, + ], + }, + [ + { key: "createdAt", direction: "DESC" }, // Converted to created_at + { key: "name", direction: "ASC" }, + ], + ); + + // Returns: + // { + // totalCount: 5000, + // filteredCount: 237, + // data: [ + // { id: 1, name: "Laptop Pro", inStock: true, ... }, + // ... + // ] + // } + + return result; +}); + +// 3. Features involved: +// - Filter parsing with 5 operators (ct, bt, eq, not) +// - Case-insensitive search (insensitive: true) +// - AND logic combining filters +// - Multi-column sorting with auto field conversion +// - Soft delete auto-filtering (if enabled) +// - Automatic camelCase → snake_case for all fields +// - postList hook enriching results +// - Limit enforcement (max 100) +// - Count information for pagination UI +``` + +--- + +## CONFIGURATION REFERENCE + +Complete configuration object structure: + +```typescript +interface SlonikOptions { + // PostgreSQL Connection Details (required) + db: { + host: string; // Database host + port: number; // Database port (default: 5432) + username: string; // Database user + password: string; // Database password + databaseName: string; // Database name + }; + + // Slonik Client Configuration (optional) + clientConfiguration?: { + captureStackTrace?: boolean; // default: false + connectionRetryLimit?: number; // default: 3 + connectionTimeout?: number; // default: 5000 ms + idleInTransactionSessionTimeout?: number; // default: 60000 ms + idleTimeout?: number; // default: 5000 ms + maximumPoolSize?: number; // default: 10 + queryRetryLimit?: number; // default: 5 + statementTimeout?: number; // default: 60000 ms + transactionRetryLimit?: number; // default: 5 + ssl?: boolean | object; // TLS/SSL config + }; + + // Pagination Settings (optional) + pagination?: { + defaultLimit?: number; // default: 20 if not specified + maxLimit?: number; // default: 50 (hard cap) + }; + + // Migration Paths (optional) + migrations?: { + development?: string; // default: "migrations" + production?: string; // default: "build/migrations" + }; + + // PostgreSQL Extensions to create (optional) + extensions?: string[]; // default: ["citext", "unaccent"] + // can add: "postgis", "uuid-ossp", etc + + // Query Logging (optional) + queryLogging?: { + enabled?: boolean; // default: false + // Requires ROARR_LOG=true env var to see output + }; +} +``` + +--- + +## FEATURE CHECKLIST FOR DEVELOPERS + +When building API endpoints with this plugin, you have these tools at your disposal: + +### Query Operations + +- [x] SELECT single by ID (`findById()`) +- [x] SELECT single by filters (`findOne()`) +- [x] SELECT multiple (`find()`) +- [x] SELECT with pagination (`list()`) +- [x] SELECT specific fields only (`all()`) +- [x] COUNT records (`count()`) + +### Filtering + +- [x] Exact match (`eq`) +- [x] Case-insensitive exact (`insensitive: true`) +- [x] String contains (`ct`) +- [x] String starts with (`sw`) +- [x] String ends with (`ew`) +- [x] Numeric range (`bt`) +- [x] Comparison operators (`gt`, `gte`, `lt`, `lte`) +- [x] IN list (`in`) +- [x] Geographic distance (`dwithin`) +- [x] NOT/negation (`not: true`) +- [x] AND/OR composition (AND/OR arrays) +- [x] Nested complex filters (arbitrary depth) + +### Sorting + +- [x] Single column sort +- [x] Multi-column sort +- [x] Ascending/descending +- [x] Case-insensitive sort (`insensitive: true`) +- [x] Default sort behavior +- [x] Automatic camelCase conversion + +### Data Operations + +- [x] Create records (`create()`) +- [x] Update records (`update()`) +- [x] Soft delete (`delete()` with soft delete enabled) +- [x] Hard delete (`delete(id, true)`) + +### Type Safety + +- [x] Zod schema validation on queries +- [x] TypeScript generics on BaseService +- [x] Type inference on results +- [x] Runtime validation + +### Auto Features + +- [x] camelCase ↔ snake_case conversion +- [x] SQL injection prevention +- [x] Connection pooling & retries +- [x] Migration execution +- [x] Extension creation (citext, unaccent, postgis, etc) +- [x] Soft delete filtering (if enabled) + +### Extensibility + +- [x] Pre-hooks (preCreate, preUpdate, preDelete, etc) +- [x] Post-hooks (postCreate, postUpdate, postDelete, etc) +- [x] Custom SQL factories +- [x] Schema-specific queries +- [x] Custom result enrichment + +--- + +## PERFORMANCE CONSIDERATIONS + +This plugin is designed for performance: + +1. **Query Efficiency**: Raw SQL via Slonik (not ORM overhead) +2. **Connection Pooling**: Max 10 connections by default, configurable +3. **Retry Logic**: Automatic retries for transient failures +4. **Lazy Initialization**: SqlFactory created only when used +5. **Limit Enforcement**: Prevents accidental massive queries +6. **Caching**: Relies on PostgreSQL query cache, not app-level caching +7. **No N+1 Problems**: Encourages explicit queries vs automatic relations + +**Best Practices**: + +- Use `find()` for specific queries, not `list()` when pagination not needed +- Use `all(fields)` to project specific columns, reducing data transfer +- Use filters with indexes to avoid full table scans +- Enable query logging during development to review generated SQL +- Use schema-specific queries if splitting by tenant + +--- + +## WHAT'S AUTOMATICALLY HANDLED + +The plugin handles these concerns transparently: + +- ✓ Establishing database connection +- ✓ Retrying failed connections +- ✓ Managing connection pool lifecycle +- ✓ Converting camelCase field names to/from snake_case +- ✓ Executing migrations at startup +- ✓ Creating PostgreSQL extensions +- ✓ Injecting database access into all requests +- ✓ Validating query results against schemas +- ✓ Preventing SQL injection +- ✓ Filtering soft-deleted records +- ✓ Type-checking query results +- ✓ Parsing PostgreSQL int8 to JavaScript numbers + +--- + +## WHAT DEVELOPERS CONTROL + +These aspects are customizable: + +- Connection pool size and timeouts +- Default and maximum pagination limits +- Migration paths per environment +- PostgreSQL extensions to create +- Field filtering and sorting logic +- Pre/post operation hook logic +- Result transformation and enrichment +- Custom SQL generation (via factory override) +- Database schema used (via 3rd param to BaseService) +- Query logging enable/disable + +--- diff --git a/packages/slonik/package.json b/packages/slonik/package.json index 7dfd12652..002b2da64 100644 --- a/packages/slonik/package.json +++ b/packages/slonik/package.json @@ -19,7 +19,9 @@ "main": "./dist/prefabs-tech-fastify-slonik.cjs", "module": "./dist/prefabs-tech-fastify-slonik.js", "types": "./dist/types/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", @@ -44,9 +46,10 @@ "@types/pg": "8.16.0", "@vitest/coverage-istanbul": "3.2.4", "eslint": "9.39.2", + "eslint-plugin-perfectionist": "5.8.0", "fastify": "5.7.4", "fastify-plugin": "5.1.0", - "pg-mem": "3.0.12", + "pg-mem": "3.0.14", "prettier": "3.8.1", "slonik": "46.8.0", "typescript": "5.9.3", diff --git a/packages/slonik/src/__test__/filters.test.ts b/packages/slonik/src/__test__/filters.test.ts index 3c347dc37..572559d2e 100644 --- a/packages/slonik/src/__test__/filters.test.ts +++ b/packages/slonik/src/__test__/filters.test.ts @@ -1,62 +1,70 @@ +import type { IdentifierSqlToken } from "slonik"; + import { sql } from "slonik"; -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; -import { applyFiltersToQuery, applyFilter } from "../filters"; +import type { BaseFilterInput, FilterInput } from "../types"; -import type { FilterInput, BaseFilterInput } from "../types"; -import type { IdentifierSqlToken } from "slonik"; +import { + applyFilter, + applyFiltersToQuery, + buildFilterFragment, +} from "../filters"; // Comprehensive dataset of filter combinations for testing const getFilterDataset = (): Array<{ - name: string; - filter: FilterInput; + description: string; expectedSQL: RegExp; expectedValues: string[]; - description: string; + filter: FilterInput; + name: string; }> => { return [ { - name: "Simple equality filter", - filter: { key: "name", operator: "eq", value: "test" }, + description: "Basic equality operation", expectedSQL: /WHERE "users"\."name" = \$slonik_\d+$/, expectedValues: ["test"], - description: "Basic equality operation", + filter: { key: "name", operator: "eq", value: "test" }, + name: "Simple equality filter", }, { - name: "Simple starts with filter", - filter: { key: "name", operator: "sw", value: "test" }, + description: "Starts with operation", expectedSQL: /WHERE "users"\."name" ILIKE \$slonik_\d+$/, expectedValues: ["test%"], - description: "Starts with operation", + filter: { key: "name", operator: "sw", value: "test" }, + name: "Simple starts with filter", }, { - name: "Simple AND operation", + description: "Simple AND with two conditions", + expectedSQL: + /WHERE \("users"\."name" ILIKE \$slonik_\d+ AND "users"\."age" > \$slonik_\d+\)/, + expectedValues: ["test%", "25"], filter: { AND: [ { key: "name", operator: "sw", value: "test" }, { key: "age", operator: "gt", value: "25" }, ], }, - expectedSQL: - /WHERE \("users"\."name" ILIKE \$slonik_\d+ AND "users"\."age" > \$slonik_\d+\)/, - expectedValues: ["test%", "25"], - description: "Simple AND with two conditions", + name: "Simple AND operation", }, { - name: "Simple OR operation", + description: "Simple OR with two conditions", + expectedSQL: + /WHERE \("users"\."name" ILIKE \$slonik_\d+ OR "users"\."name" ILIKE \$slonik_\d+\)/, + expectedValues: ["Test%", "%t1"], filter: { OR: [ { key: "name", operator: "sw", value: "Test" }, { key: "name", operator: "ew", value: "t1" }, ], }, - expectedSQL: - /WHERE \("users"\."name" ILIKE \$slonik_\d+ OR "users"\."name" ILIKE \$slonik_\d+\)/, - expectedValues: ["Test%", "%t1"], - description: "Simple OR with two conditions", + name: "Simple OR operation", }, { - name: "AND with nested OR", + description: "AND containing OR - tests proper nesting", + expectedSQL: + /WHERE \("users"\."id" > \$slonik_\d+ AND \("users"\."name" ILIKE \$slonik_\d+ OR "users"\."name" ILIKE \$slonik_\d+\)\)/, + expectedValues: ["10", "Test%", "%t1"], filter: { AND: [ { key: "id", operator: "gt", value: "10" }, @@ -68,13 +76,13 @@ const getFilterDataset = (): Array<{ }, ], }, - expectedSQL: - /WHERE \("users"\."id" > \$slonik_\d+ AND \("users"\."name" ILIKE \$slonik_\d+ OR "users"\."name" ILIKE \$slonik_\d+\)\)/, - expectedValues: ["10", "Test%", "%t1"], - description: "AND containing OR - tests proper nesting", + name: "AND with nested OR", }, { - name: "OR with nested AND", + description: "OR containing AND - tests proper nesting", + expectedSQL: + /WHERE \("users"\."id" > \$slonik_\d+ OR \("users"\."name" ILIKE \$slonik_\d+ AND "users"\."name" ILIKE \$slonik_\d+\)\)/, + expectedValues: ["10", "Test%", "%t1"], filter: { OR: [ { key: "id", operator: "gt", value: "10" }, @@ -86,13 +94,13 @@ const getFilterDataset = (): Array<{ }, ], }, - expectedSQL: - /WHERE \("users"\."id" > \$slonik_\d+ OR \("users"\."name" ILIKE \$slonik_\d+ AND "users"\."name" ILIKE \$slonik_\d+\)\)/, - expectedValues: ["10", "Test%", "%t1"], - description: "OR containing AND - tests proper nesting", + name: "OR with nested AND", }, { - name: "OR with multiple AND blocks", + description: "OR with multiple AND blocks - tests complex grouping", + expectedSQL: + /WHERE \(\("users"\."name" ILIKE \$slonik_\d+ AND "users"\."age" > \$slonik_\d+\) OR \("users"\."email" ILIKE \$slonik_\d+ AND "users"\."status" = \$slonik_\d+\)\)/, + expectedValues: ["Test%", "25", "%@test.com", "active"], filter: { OR: [ { @@ -109,13 +117,13 @@ const getFilterDataset = (): Array<{ }, ], }, - expectedSQL: - /WHERE \(\("users"\."name" ILIKE \$slonik_\d+ AND "users"\."age" > \$slonik_\d+\) OR \("users"\."email" ILIKE \$slonik_\d+ AND "users"\."status" = \$slonik_\d+\)\)/, - expectedValues: ["Test%", "25", "%@test.com", "active"], - description: "OR with multiple AND blocks - tests complex grouping", + name: "OR with multiple AND blocks", }, { - name: "Complex nested structure", + description: "Deep nesting: OR[AND[condition, OR[...]], AND[...]]", + expectedSQL: + /WHERE \(\("users"\."name" ILIKE \$slonik_\d+ AND \("users"\."department" = \$slonik_\d+ OR "users"\."department" = \$slonik_\d+\)\) OR \("users"\."role" = \$slonik_\d+ AND "users"\."verified" = \$slonik_\d+\)\)/, + expectedValues: ["Test%", "engineering", "design", "admin", "true"], filter: { OR: [ { @@ -137,13 +145,13 @@ const getFilterDataset = (): Array<{ }, ], }, - expectedSQL: - /WHERE \(\("users"\."name" ILIKE \$slonik_\d+ AND \("users"\."department" = \$slonik_\d+ OR "users"\."department" = \$slonik_\d+\)\) OR \("users"\."role" = \$slonik_\d+ AND "users"\."verified" = \$slonik_\d+\)\)/, - expectedValues: ["Test%", "engineering", "design", "admin", "true"], - description: "Deep nesting: OR[AND[condition, OR[...]], AND[...]]", + name: "Complex nested structure", }, { - name: "Triple nested structure", + description: "Triple nesting: AND[condition, OR[AND[...], OR[...]]]", + expectedSQL: + /WHERE \("users"\."status" = \$slonik_\d+ AND \(\("users"\."age" >= \$slonik_\d+ AND "users"\."age" <= \$slonik_\d+\) OR \("users"\."role" = \$slonik_\d+ OR "users"\."special" = \$slonik_\d+\)\)\)/, + expectedValues: ["active", "18", "65", "admin", "true"], filter: { AND: [ { key: "status", operator: "eq", value: "active" }, @@ -165,31 +173,31 @@ const getFilterDataset = (): Array<{ }, ], }, - expectedSQL: - /WHERE \("users"\."status" = \$slonik_\d+ AND \(\("users"\."age" >= \$slonik_\d+ AND "users"\."age" <= \$slonik_\d+\) OR \("users"\."role" = \$slonik_\d+ OR "users"\."special" = \$slonik_\d+\)\)\)/, - expectedValues: ["active", "18", "65", "admin", "true"], - description: "Triple nesting: AND[condition, OR[AND[...], OR[...]]]", + name: "Triple nested structure", }, { - name: "Single condition in AND array", + description: "Single condition should not have extra parentheses", + expectedSQL: /WHERE "users"\."name" = \$slonik_\d+$/, + expectedValues: ["test"], filter: { AND: [{ key: "name", operator: "eq", value: "test" }], }, - expectedSQL: /WHERE "users"\."name" = \$slonik_\d+$/, - expectedValues: ["test"], - description: "Single condition should not have extra parentheses", + name: "Single condition in AND array", }, { - name: "Single condition in OR array", + description: "Single condition should not have extra parentheses", + expectedSQL: /WHERE "users"\."status" = \$slonik_\d+$/, + expectedValues: ["active"], filter: { OR: [{ key: "status", operator: "eq", value: "active" }], }, - expectedSQL: /WHERE "users"\."status" = \$slonik_\d+$/, - expectedValues: ["active"], - description: "Single condition should not have extra parentheses", + name: "Single condition in OR array", }, { - name: "Multiple operators test", + description: "Tests all different operators", + expectedSQL: + /WHERE \("users"\."name" ILIKE \$slonik_\d+ AND "users"\."age" BETWEEN \$slonik_\d+ AND \$slonik_\d+ AND "users"\."status" IN \(\$slonik_\d+, \$slonik_\d+\) AND "users"\."deleted_at" IS NULL\)/, + expectedValues: ["%test%", "25", "65", "active", "pending"], filter: { AND: [ { key: "name", operator: "ct", value: "test" }, @@ -198,28 +206,25 @@ const getFilterDataset = (): Array<{ { key: "deletedAt", operator: "eq", value: "null" }, ], }, - expectedSQL: - /WHERE \("users"\."name" ILIKE \$slonik_\d+ AND "users"\."age" BETWEEN \$slonik_\d+ AND \$slonik_\d+ AND "users"\."status" IN \(\$slonik_\d+, \$slonik_\d+\) AND "users"\."deleted_at" IS NULL\)/, - expectedValues: ["%test%", "25", "65", "active", "pending"], - description: "Tests all different operators", + name: "Multiple operators test", }, { - name: "NOT flag operations", + description: "Tests NOT flag with different operators", + expectedSQL: + /WHERE \("users"\."name" != \$slonik_\d+ AND "users"\."status" NOT IN \(\$slonik_\d+, \$slonik_\d+\)\)/, + expectedValues: ["test", "inactive", "banned"], filter: { AND: [ - { key: "name", operator: "eq", value: "test", not: true }, + { key: "name", not: true, operator: "eq", value: "test" }, { key: "status", + not: true, operator: "in", value: "inactive,banned", - not: true, }, ], }, - expectedSQL: - /WHERE \("users"\."name" != \$slonik_\d+ AND "users"\."status" NOT IN \(\$slonik_\d+, \$slonik_\d+\)\)/, - expectedValues: ["test", "inactive", "banned"], - description: "Tests NOT flag with different operators", + name: "NOT flag operations", }, ]; }; @@ -248,9 +253,9 @@ describe("dbFilters", () => { it("should handle equality operator with not flag", () => { const filter: BaseFilterInput = { key: "name", + not: true, operator: "eq", value: "John", - not: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -275,9 +280,9 @@ describe("dbFilters", () => { it("should handle null values with not flag", () => { const filter: BaseFilterInput = { key: "deletedAt", + not: true, operator: "eq", value: "NULL", - not: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -486,9 +491,9 @@ describe("dbFilters", () => { it("should handle join table key with NOT flag", () => { const filter: BaseFilterInput = { key: "posts.status", + not: true, operator: "in", value: "draft,archived", - not: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -513,9 +518,9 @@ describe("dbFilters", () => { it("should handle join table key with null values and NOT flag", () => { const filter: BaseFilterInput = { key: "comments.deletedAt", + not: true, operator: "eq", value: "NULL", - not: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -529,10 +534,10 @@ describe("dbFilters", () => { describe("applyFilter > case insensitive", () => { it("should handle equality operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "name", operator: "eq", value: "John", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -545,11 +550,11 @@ describe("dbFilters", () => { it("should handle equality operator with not flag", () => { const filter: BaseFilterInput = { + insensitive: true, key: "name", + not: true, operator: "eq", value: "John", - not: true, - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -562,10 +567,10 @@ describe("dbFilters", () => { it("should handle null values", () => { const filter: BaseFilterInput = { + insensitive: true, key: "deletedAt", operator: "eq", value: "null", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -576,11 +581,11 @@ describe("dbFilters", () => { it("should handle null values with not flag", () => { const filter: BaseFilterInput = { + insensitive: true, key: "deletedAt", + not: true, operator: "eq", value: "NULL", - not: true, - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -591,10 +596,10 @@ describe("dbFilters", () => { it("should handle contains operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "name", operator: "ct", value: "John", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -607,10 +612,10 @@ describe("dbFilters", () => { it("should handle starts with operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "name", operator: "sw", value: "John", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -623,10 +628,10 @@ describe("dbFilters", () => { it("should handle ends with operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "name", operator: "ew", value: "son", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -639,10 +644,10 @@ describe("dbFilters", () => { it("should handle greater than operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "age", operator: "gt", value: "25", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -655,10 +660,10 @@ describe("dbFilters", () => { it("should handle greater than or equal operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "age", operator: "gte", value: "25", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -671,10 +676,10 @@ describe("dbFilters", () => { it("should handle less than operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "age", operator: "lt", value: "65", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -687,10 +692,10 @@ describe("dbFilters", () => { it("should handle less than or equal operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "age", operator: "lte", value: "65", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -703,10 +708,10 @@ describe("dbFilters", () => { it("should handle in operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "status", operator: "in", value: "active,inactive,pending", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -719,10 +724,10 @@ describe("dbFilters", () => { it("should handle between operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "age", operator: "bt", value: "25,65", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -735,10 +740,10 @@ describe("dbFilters", () => { it("should convert camelCase keys to snake_case", () => { const filter: BaseFilterInput = { + insensitive: true, key: "firstName", operator: "eq", value: "John", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -751,10 +756,10 @@ describe("dbFilters", () => { it("should handle schema.table identifiers", () => { const filter: BaseFilterInput = { + insensitive: true, key: "name", operator: "eq", value: "John", - insensitive: true, }; const result = applyFilter(mockSchemaTableIdentifier, filter); @@ -822,9 +827,9 @@ describe("dbFilters", () => { it("should handle join table key with NOT flag", () => { const filter: BaseFilterInput = { key: "posts.status", + not: true, operator: "in", value: "draft,archived", - not: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -849,9 +854,9 @@ describe("dbFilters", () => { it("should handle join table key with null values and NOT flag", () => { const filter: BaseFilterInput = { key: "comments.deletedAt", + not: true, operator: "eq", value: "NULL", - not: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -866,8 +871,8 @@ describe("dbFilters", () => { it("should default to eq operator if not provided", () => { const filter = { key: "name", - value: "John", operator: "eq", + value: "John", } as BaseFilterInput; const result = applyFilter(mockTableIdentifier, filter); @@ -890,9 +895,9 @@ describe("dbFilters", () => { it("should throw error for empty IN list with NOT", () => { const filter: BaseFilterInput = { key: "status", + not: true, operator: "in", value: "", - not: true, }; expect(() => applyFilter(mockTableIdentifier, filter)).toThrow( @@ -1195,17 +1200,17 @@ describe("dbFilters", () => { AND: [ { key: "posts.status", + not: true, operator: "eq", value: "published", - not: true, }, { key: "comments.isSpam", + not: true, operator: "eq", value: "true", - not: true, }, - { key: "tags.isHidden", operator: "eq", value: "null", not: true }, + { key: "tags.isHidden", not: true, operator: "eq", value: "null" }, ], }; @@ -1273,3 +1278,87 @@ describe("dbFilters", () => { }); }); }); + +describe("applyFilter — dwithin operator", () => { + const mockTableIdentifier: IdentifierSqlToken = sql.identifier(["locations"]); + + it("generates ST_DWithin SQL with lat, lng, radius from value string", () => { + const filter: BaseFilterInput = { + key: "coordinates", + operator: "dwithin", + value: "48.8566,2.3522,1000", + }; + + const result = applyFilter(mockTableIdentifier, filter); + + expect(result.sql).toMatch(/ST_DWithin/); + expect(result.sql).toMatch(/ST_SetSRID/); + expect(result.sql).toMatch(/ST_MakePoint/); + }); + + it("places longitude before latitude in ST_MakePoint (GeoJSON order)", () => { + const filter: BaseFilterInput = { + key: "coordinates", + operator: "dwithin", + value: "48.8566,2.3522,500", + }; + + const result = applyFilter(mockTableIdentifier, filter); + + // ST_MakePoint(longitude, latitude) — values[0]=lng=2.3522, values[1]=lat=48.8566 + expect(result.values).toContain("2.3522"); + expect(result.values).toContain("48.8566"); + }); + + it("includes the radius value", () => { + const filter: BaseFilterInput = { + key: "coordinates", + operator: "dwithin", + value: "40.7128,-74.0060,5000", + }; + + const result = applyFilter(mockTableIdentifier, filter); + expect(result.values).toContain("5000"); + }); +}); + +describe("buildFilterFragment — empty input paths", () => { + const mockTableIdentifier: IdentifierSqlToken = sql.identifier(["users"]); + + it("returns undefined for empty AND array", () => { + const result = buildFilterFragment({ AND: [] }, mockTableIdentifier); + expect(result).toBeUndefined(); + }); + + it("returns undefined for empty OR array", () => { + const result = buildFilterFragment({ OR: [] }, mockTableIdentifier); + expect(result).toBeUndefined(); + }); + + it("returns single fragment directly when AND has one item (no extra parens)", () => { + const result = buildFilterFragment( + { AND: [{ key: "name", operator: "eq", value: "alice" }] }, + mockTableIdentifier, + ); + expect(result?.sql).not.toMatch(/^\(/); + expect(result?.sql).toMatch(/"users"\."name"/); + }); + + it("returns single fragment directly when OR has one item (no extra parens)", () => { + const result = buildFilterFragment( + { OR: [{ key: "name", operator: "eq", value: "alice" }] }, + mockTableIdentifier, + ); + expect(result?.sql).not.toMatch(/^\(/); + expect(result?.sql).toMatch(/"users"\."name"/); + }); + + it("returns undefined for null/undefined filter", () => { + const result = buildFilterFragment( + // eslint-disable-next-line unicorn/no-null + null as unknown as FilterInput, + mockTableIdentifier, + ); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/slonik/src/__test__/helpers/createConfig.ts b/packages/slonik/src/__test__/helpers/createConfig.ts index 3e379d488..165aff9f8 100644 --- a/packages/slonik/src/__test__/helpers/createConfig.ts +++ b/packages/slonik/src/__test__/helpers/createConfig.ts @@ -1,6 +1,7 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + /* istanbul ignore file */ import type { SlonikOptions } from "../../types"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; const createConfig = (slonikOptions?: SlonikOptions) => { const config: ApiConfig = { @@ -17,7 +18,6 @@ const createConfig = (slonikOptions?: SlonikOptions) => { rest: { enabled: true, }, - version: "0.1", slonik: { db: { databaseName: "test", @@ -27,6 +27,7 @@ const createConfig = (slonikOptions?: SlonikOptions) => { }, ...slonikOptions, }, + version: "0.1", }; return config; diff --git a/packages/slonik/src/__test__/helpers/createDatabase.ts b/packages/slonik/src/__test__/helpers/createDatabase.ts index 185b6ac6c..342eb16fe 100644 --- a/packages/slonik/src/__test__/helpers/createDatabase.ts +++ b/packages/slonik/src/__test__/helpers/createDatabase.ts @@ -1,10 +1,10 @@ +import type { IMemoryDb, SlonikAdapterOptions } from "pg-mem"; + /* istanbul ignore file */ import { newDb } from "pg-mem"; import fieldNameCaseConverter from "../../interceptors/fieldNameCaseConverter"; -import type { SlonikAdapterOptions, IMemoryDb } from "pg-mem"; - interface IOptions { db?: IMemoryDb; slonikAdapterOptions?: SlonikAdapterOptions; diff --git a/packages/slonik/src/__test__/helpers/testService.ts b/packages/slonik/src/__test__/helpers/testService.ts index 9901c2a67..e43a5bda5 100644 --- a/packages/slonik/src/__test__/helpers/testService.ts +++ b/packages/slonik/src/__test__/helpers/testService.ts @@ -1,8 +1,8 @@ -import TestSqlFactory from "./sqlFactory"; -import BaseService from "../../service"; - import type { QueryResultRow } from "slonik"; +import BaseService from "../../service"; +import TestSqlFactory from "./sqlFactory"; + class TestService< T extends QueryResultRow, C extends QueryResultRow, diff --git a/packages/slonik/src/__test__/helpers/utils.ts b/packages/slonik/src/__test__/helpers/utils.ts index 548ace2a7..b008593eb 100644 --- a/packages/slonik/src/__test__/helpers/utils.ts +++ b/packages/slonik/src/__test__/helpers/utils.ts @@ -105,13 +105,13 @@ const getLimitAndOffsetDataset = async (count: number, config: ApiConfig) => { const getSortDataset = (): SortInput[][] => { return [ - [{ key: "name", direction: "ASC" }], - [{ key: "id", direction: "DESC" }], - [{ key: "countryCode", direction: "ASC" }], - [{ key: "country_code", direction: "DESC" }], + [{ direction: "ASC", key: "name" }], + [{ direction: "DESC", key: "id" }], + [{ direction: "ASC", key: "countryCode" }], + [{ direction: "DESC", key: "country_code" }], [ - { key: "id", direction: "DESC" }, - { key: "name", direction: "ASC" }, + { direction: "DESC", key: "id" }, + { direction: "ASC", key: "name" }, ], ]; }; diff --git a/packages/slonik/src/__test__/migrate.test.ts b/packages/slonik/src/__test__/migrate.test.ts new file mode 100644 index 000000000..531e2e779 --- /dev/null +++ b/packages/slonik/src/__test__/migrate.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SlonikOptions } from "../types"; + +const pgClientConstructorMock = vi.fn(); +const pgClientConnectMock = vi.fn().mockResolvedValue(); +const pgClientEndMock = vi.fn().mockResolvedValue(); + +vi.mock("pg", () => { + const Client = pgClientConstructorMock.mockImplementation(() => ({ + connect: pgClientConnectMock, + end: pgClientEndMock, + query: vi.fn().mockResolvedValue({ rows: [] }), + })); + return { Client, default: { Client } }; +}); + +vi.mock("@prefabs.tech/postgres-migrations", () => ({ + migrate: vi.fn().mockResolvedValue(), +})); + +const baseOptions: SlonikOptions = { + db: { + databaseName: "testdb", + host: "localhost", + password: "pass", + username: "user", + }, +}; + +describe("migrate — default migration path", async () => { + const { default: migrate } = await import("../migrate"); + const { migrate: runMigrationsMock } = + await import("@prefabs.tech/postgres-migrations"); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses 'migrations' as default path when options.migrations.path is not set", async () => { + await migrate(baseOptions); + expect(runMigrationsMock).toHaveBeenCalledWith( + expect.any(Object), + "migrations", + ); + }); + + it("uses provided path when options.migrations.path is set", async () => { + await migrate({ ...baseOptions, migrations: { path: "build/migrations" } }); + expect(runMigrationsMock).toHaveBeenCalledWith( + expect.any(Object), + "build/migrations", + ); + }); + + it("passes db credentials to pg.Client", async () => { + await migrate(baseOptions); + expect(pgClientConstructorMock).toHaveBeenCalledWith( + expect.objectContaining({ + database: "testdb", + host: "localhost", + password: "pass", + user: "user", + }), + ); + }); + + it("includes ssl in pg.Client config when clientConfiguration.ssl is set", async () => { + const ssl = { rejectUnauthorized: false }; + await migrate({ + ...baseOptions, + clientConfiguration: { ssl } as never, + }); + expect(pgClientConstructorMock).toHaveBeenCalledWith( + expect.objectContaining({ ssl }), + ); + }); + + it("does not include ssl in pg.Client config when clientConfiguration.ssl is not set", async () => { + await migrate(baseOptions); + const callArgument = pgClientConstructorMock.mock.calls[0][0]; + expect(callArgument).not.toHaveProperty("ssl"); + }); + + it("connects and ends the pg client", async () => { + await migrate(baseOptions); + expect(pgClientConnectMock).toHaveBeenCalledOnce(); + expect(pgClientEndMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/slonik/src/__test__/migrationPlugin.test.ts b/packages/slonik/src/__test__/migrationPlugin.test.ts new file mode 100644 index 000000000..82c7c81db --- /dev/null +++ b/packages/slonik/src/__test__/migrationPlugin.test.ts @@ -0,0 +1,66 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SlonikOptions } from "../types"; + +const migrateMock = vi.fn().mockResolvedValue(); + +vi.mock("../migrate", () => ({ + default: migrateMock, +})); + +const baseOptions: SlonikOptions = { + db: { + databaseName: "test", + host: "localhost", + password: "pass", + username: "user", + }, +}; + +describe("migrationPlugin — registration", async () => { + const { default: plugin } = await import("../migrationPlugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + }); + + it("registers without throwing", async () => { + await expect(fastify.register(plugin, baseOptions)).resolves.not.toThrow(); + }); + + it("calls migrate with the provided options", async () => { + await fastify.register(plugin, baseOptions); + await fastify.ready(); + expect(migrateMock).toHaveBeenCalledWith(baseOptions); + }); +}); + +describe("migrationPlugin — legacy config fallback", async () => { + const { default: plugin } = await import("../migrationPlugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + }); + + it("reads options from fastify.config.slonik when no options passed", async () => { + fastify.decorate("config", { slonik: baseOptions }); + await fastify.register(plugin); + await fastify.ready(); + expect(migrateMock).toHaveBeenCalledWith(baseOptions); + }); + + it("throws descriptive error when no options and no fastify.config.slonik", async () => { + await expect(fastify.register(plugin)).rejects.toThrow( + "Missing migration configuration. Did you forget to pass it to the migration plugin?", + ); + }); +}); diff --git a/packages/slonik/src/__test__/plugin.test.ts b/packages/slonik/src/__test__/plugin.test.ts new file mode 100644 index 000000000..926863093 --- /dev/null +++ b/packages/slonik/src/__test__/plugin.test.ts @@ -0,0 +1,192 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SlonikOptions } from "../types"; + +const runMigrationsMock = vi.fn().mockResolvedValue(); +const stringifyDsnMock = vi + .fn() + .mockReturnValue("postgresql://user:pass@localhost/test"); +const createClientConfigurationMock = vi + .fn() + .mockReturnValue({ interceptors: [] }); + +// fastifySlonik must be wrapped with fastify-plugin so its decorations escape +// the child scope and reach the parent fastify instance. +vi.mock("../slonik", async () => { + const { default: FastifyPlugin } = await import("fastify-plugin"); + + const fakeSlonik = { + connect: vi.fn(), + pool: {}, + query: vi.fn(), + }; + + return { + fastifySlonik: FastifyPlugin(async (fastify: FastifyInstance) => { + if (!fastify.hasDecorator("slonik")) + fastify.decorate("slonik", fakeSlonik); + if (!fastify.hasDecorator("sql")) fastify.decorate("sql", {}); + if (!fastify.hasRequestDecorator("slonik")) + fastify.decorateRequest("slonik"); + if (!fastify.hasRequestDecorator("sql")) fastify.decorateRequest("sql"); + }), + }; +}); + +vi.mock("../migrations/runMigrations", () => ({ + default: runMigrationsMock, +})); + +vi.mock("slonik", () => ({ + stringifyDsn: stringifyDsnMock, +})); + +vi.mock("../factories/createClientConfiguration", () => ({ + default: createClientConfigurationMock, +})); + +const baseOptions: SlonikOptions = { + db: { + databaseName: "test", + host: "localhost", + password: "pass", + username: "user", + }, +}; + +describe("slonikPlugin — registration", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + }); + + it("registers without throwing", async () => { + await expect(fastify.register(plugin, baseOptions)).resolves.not.toThrow(); + }); + + it("decorates fastify.slonik after registration", async () => { + await fastify.register(plugin, baseOptions); + await fastify.ready(); + expect(fastify.slonik).toBeDefined(); + }); + + it("decorates fastify.sql after registration", async () => { + await fastify.register(plugin, baseOptions); + await fastify.ready(); + expect(fastify.sql).toBeDefined(); + }); + + it("decorates req.dbSchema as empty string in route handlers", async () => { + await fastify.register(plugin, baseOptions); + + fastify.get("/test", async (req) => { + return { dbSchema: req.dbSchema }; + }); + + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().dbSchema).toBe(""); + }); + + it("calls stringifyDsn with options.db", async () => { + await fastify.register(plugin, baseOptions); + await fastify.ready(); + expect(stringifyDsnMock).toHaveBeenCalledWith(baseOptions.db); + }); + + it("calls createClientConfiguration with clientConfiguration and queryLogging.enabled", async () => { + const options: SlonikOptions = { + ...baseOptions, + queryLogging: { enabled: true }, + }; + await fastify.register(plugin, options); + await fastify.ready(); + expect(createClientConfigurationMock).toHaveBeenCalledWith( + options.clientConfiguration, + true, + ); + }); + + it("passes undefined for queryLogging.enabled when queryLogging is not set", async () => { + await fastify.register(plugin, baseOptions); + await fastify.ready(); + expect(createClientConfigurationMock).toHaveBeenCalledWith( + undefined, + undefined, + ); + }); + + it("passes false for queryLogging.enabled when query logging is explicitly disabled", async () => { + const options: SlonikOptions = { + ...baseOptions, + queryLogging: { enabled: false }, + }; + await fastify.register(plugin, options); + await fastify.ready(); + expect(createClientConfigurationMock).toHaveBeenCalledWith( + options.clientConfiguration, + false, + ); + }); + + it("runs extension and migration setup after the pool is decorated", async () => { + const options: SlonikOptions = { + ...baseOptions, + extensions: ["uuid-ossp"], + }; + await fastify.register(plugin, options); + await fastify.ready(); + expect(runMigrationsMock).toHaveBeenCalledTimes(1); + expect(runMigrationsMock).toHaveBeenCalledWith(fastify.slonik, options); + }); +}); + +describe("slonikPlugin — legacy config fallback", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + }); + + it("reads options from fastify.config.slonik when no options passed", async () => { + const warnSpy = vi.spyOn(fastify.log, "warn").mockImplementation(() => {}); + fastify.decorate("config", { slonik: baseOptions }); + await fastify.register(plugin); + await fastify.ready(); + expect(stringifyDsnMock).toHaveBeenCalledWith(baseOptions.db); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "The slonik plugin now recommends passing slonik options directly", + ), + ); + }); + + it("fastify.slonik is available after legacy registration", async () => { + fastify.decorate("config", { slonik: baseOptions }); + await fastify.register(plugin); + await fastify.ready(); + expect(fastify.slonik).toBeDefined(); + }); + + it("throws descriptive error when no options and no fastify.config.slonik", async () => { + await expect(fastify.register(plugin)).rejects.toThrow( + "Missing slonik configuration. Did you forget to pass it to the slonik plugin?", + ); + }); + + it("runs extension setup with config resolved from fastify.config.slonik", async () => { + fastify.decorate("config", { slonik: baseOptions }); + await fastify.register(plugin); + await fastify.ready(); + expect(runMigrationsMock).toHaveBeenCalledWith(fastify.slonik, baseOptions); + }); +}); diff --git a/packages/slonik/src/__test__/service.test.ts b/packages/slonik/src/__test__/service.test.ts index 8a549103f..bbe7bb96e 100644 --- a/packages/slonik/src/__test__/service.test.ts +++ b/packages/slonik/src/__test__/service.test.ts @@ -2,6 +2,8 @@ import { newDb } from "pg-mem"; import { describe, expect, it } from "vitest"; +import type { SlonikOptions } from "../types"; + import createConfig from "./helpers/createConfig"; import createDatabase from "./helpers/createDatabase"; import TestSqlFactory from "./helpers/sqlFactory"; @@ -12,8 +14,6 @@ import { getSortDataset, } from "./helpers/utils"; -import type { SlonikOptions } from "../types"; - describe("Service", async () => { const db = newDb(); @@ -111,8 +111,8 @@ describe("Service", async () => { it("calls database with correct sql query for find method", async () => { const result = [ - { id: 1, name: "Test1", countryCode: "NP", latitude: 20 }, - { id: 2, name: "Test2", countryCode: "US", latitude: 30 }, + { countryCode: "NP", id: 1, latitude: 20, name: "Test1" }, + { countryCode: "US", id: 2, latitude: 30, name: "Test2" }, ]; const service = new TestService(config, database); @@ -123,24 +123,24 @@ describe("Service", async () => { }); it("calls database with correct sql query for findOne method", async () => { - const result = { id: 1, name: "Test1", countryCode: "NP", latitude: 20 }; + const result = { countryCode: "NP", id: 1, latitude: 20, name: "Test1" }; const service = new TestService(config, database); const response = await service.findOne( { key: "id", operator: "eq", value: "1" }, - [{ key: "id", direction: "ASC" }], + [{ direction: "ASC", key: "id" }], ); expect(response).toStrictEqual(result); }); it("calls database with correct sql query for create method", async () => { - const result = [{ id: 3, name: "Test", latitude: 20, countryCode: "FR" }]; + const result = [{ countryCode: "FR", id: 3, latitude: 20, name: "Test" }]; const service = new TestService(config, database); - const data = { name: "Test", latitude: 20, countryCode: "FR" }; + const data = { countryCode: "FR", latitude: 20, name: "Test" }; const response = await service.create(data); @@ -150,7 +150,7 @@ describe("Service", async () => { it("calls database with correct sql query for findById method", async () => { const data = 1; - const result = [{ id: 1, name: "Test1", latitude: 20, countryCode: "NP" }]; + const result = [{ countryCode: "NP", id: 1, latitude: 20, name: "Test1" }]; const service = new TestService(config, database); @@ -162,7 +162,7 @@ describe("Service", async () => { it("calls database with correct sql query for update method", async () => { const data = { name: "updated test" }; - const result = [{ id: 1, ...data, latitude: 20, countryCode: "NP" }]; + const result = [{ id: 1, ...data, countryCode: "NP", latitude: 20 }]; const service = new TestService(config, database); @@ -174,7 +174,7 @@ describe("Service", async () => { it("calls database with correct sql query for delete method", async () => { const id = 3; - const result = [{ id: 3, name: "Test", latitude: 20, countryCode: "FR" }]; + const result = [{ countryCode: "FR", id: 3, latitude: 20, name: "Test" }]; const service = new TestService(config, database); @@ -264,3 +264,45 @@ describe("Service", async () => { } }); }); + +describe("BaseService — isCompatibleType edge cases", async () => { + const db = newDb(); + db.public.none( + `create table test(id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name TEXT, latitude INT, country_code TEXT); + insert into test (name, latitude, country_code) values ('Edge1', 10, 'DE');`, + ); + const config = createConfig(); + const database = await createDatabase({ db }); + + class InspectService extends TestService< + Record, + Record, + Record + > { + // Expose protected method for testing + testIsCompatibleType(processed: T, original: T): boolean { + return this.isCompatibleType(processed, original); + } + } + + it("returns true when original is undefined (processed can be anything)", () => { + const service = new InspectService(config, database); + expect(service.testIsCompatibleType("anything")).toBe(true); + }); + + it("returns false when processed is array but original is plain object", () => { + const service = new InspectService(config, database); + expect(service.testIsCompatibleType([], {})).toBe(false); + }); + + it("returns false when processed is object but original is array", () => { + const service = new InspectService(config, database); + expect(service.testIsCompatibleType({}, [])).toBe(false); + }); + + it("returns false when null processed does not match non-null original", () => { + const service = new InspectService(config, database); + // eslint-disable-next-line unicorn/no-null + expect(service.testIsCompatibleType(null, {})).toBe(false); + }); +}); diff --git a/packages/slonik/src/__test__/serviceWithHooks.test.ts b/packages/slonik/src/__test__/serviceWithHooks.test.ts index 0913cdbd4..fcbe59016 100644 --- a/packages/slonik/src/__test__/serviceWithHooks.test.ts +++ b/packages/slonik/src/__test__/serviceWithHooks.test.ts @@ -1,57 +1,21 @@ +import type { QueryResultRow } from "slonik"; + /* istanbul ignore file */ import { newDb } from "pg-mem"; import { describe, expect, it } from "vitest"; +import type { PaginatedList } from "../types"; + import createConfig from "./helpers/createConfig"; import createDatabase from "./helpers/createDatabase"; import TestService from "./helpers/testService"; -import type { PaginatedList } from "../types"; -import type { QueryResultRow } from "slonik"; - // Test service with hooks implementation class TestServiceWithHooks< T extends QueryResultRow, C extends QueryResultRow, U extends QueryResultRow, > extends TestService { - // Pre-hooks - async preAll() { - // No processing needed for read operations - } - - async preCount() { - // No processing needed for read operations - } - - async preCreate(data: C): Promise { - return { ...data, name: `pre-${data.name}` } as C; - } - - async preDelete() { - // No processing needed for delete operation - } - - async preFind() { - // No processing needed for read operations - } - - async preFindById() { - // No processing needed for read operations - } - - async preFindOne() { - // No processing needed for read operations - } - - async preList() { - // No processing needed for read operations - } - - async preUpdate(data: U): Promise { - return { ...data, name: `pre-${data.name}` } as U; - } - // Post-hooks async postAll(result: Partial): Promise> { return result.map((item) => ({ @@ -97,6 +61,43 @@ class TestServiceWithHooks< async postUpdate(result: T): Promise { return { ...result, processed: "updated" } as T; } + + // Pre-hooks + async preAll() { + // No processing needed for read operations + } + + async preCount() { + // No processing needed for read operations + } + + async preCreate(data: C): Promise { + return { ...data, name: `pre-${data.name}` } as C; + } + + async preDelete() { + // No processing needed for delete operation + } + + async preFind() { + // No processing needed for read operations + } + + async preFindById() { + // No processing needed for read operations + } + + async preFindOne() { + // No processing needed for read operations + } + + async preList() { + // No processing needed for read operations + } + + async preUpdate(data: U): Promise { + return { ...data, name: `pre-${data.name}` } as U; + } } // Service with invalid hooks (returning wrong types) @@ -105,13 +106,13 @@ class TestServiceWithInvalidHooks< C extends QueryResultRow, U extends QueryResultRow, > extends TestService { - async preCreate(): Promise { - return "invalid-type" as unknown as C; - } - async postCreate(): Promise { return "invalid-type" as unknown as T; } + + async preCreate(): Promise { + return "invalid-type" as unknown as C; + } } describe("Service Hooks", async () => { @@ -128,7 +129,7 @@ describe("Service Hooks", async () => { describe("Pre-hooks", () => { it("calls preCreate hook and modifies data before create", async () => { const service = new TestServiceWithHooks(config, database); - const data = { name: "Test", latitude: 20, countryCode: "FR" }; + const data = { countryCode: "FR", latitude: 20, name: "Test" }; const result = await service.create(data); @@ -148,7 +149,7 @@ describe("Service Hooks", async () => { it("handles missing pre-hooks gracefully", async () => { const service = new TestService(config, database); - const data = { name: "Test", latitude: 20, countryCode: "FR" }; + const data = { countryCode: "FR", latitude: 20, name: "Test" }; const result = await service.create(data); @@ -157,7 +158,7 @@ describe("Service Hooks", async () => { it("returns original data when pre-hook returns wrong type", async () => { const service = new TestServiceWithInvalidHooks(config, database); - const data = { name: "Test", latitude: 20, countryCode: "FR" }; + const data = { countryCode: "FR", latitude: 20, name: "Test" }; const result = await service.create(data); @@ -167,7 +168,7 @@ describe("Service Hooks", async () => { it("verifies pre-hooks are called and modify data", async () => { const service = new TestServiceWithHooks(config, database); - const data = { name: "TrackingTest", latitude: 25, countryCode: "DE" }; + const data = { countryCode: "DE", latitude: 25, name: "TrackingTest" }; const result = await service.create(data); @@ -201,7 +202,7 @@ describe("Service Hooks", async () => { it("calls postCreate hook and modifies result", async () => { const service = new TestServiceWithHooks(config, database); - const data = { name: "PostTest", latitude: 15, countryCode: "IT" }; + const data = { countryCode: "IT", latitude: 15, name: "PostTest" }; const result = await service.create(data); @@ -261,7 +262,7 @@ describe("Service Hooks", async () => { const service = new TestServiceWithHooks(config, database); // First create a record to delete - const createData = { name: "ToDelete", latitude: 50, countryCode: "CA" }; + const createData = { countryCode: "CA", latitude: 50, name: "ToDelete" }; const created = await service.create(createData); const result = await service.delete(created!.id as number); @@ -280,7 +281,7 @@ describe("Service Hooks", async () => { it("returns original result when post-hook returns wrong type", async () => { const service = new TestServiceWithInvalidHooks(config, database); - const data = { name: "Test", latitude: 20, countryCode: "FR" }; + const data = { countryCode: "FR", latitude: 20, name: "Test" }; const result = await service.create(data); @@ -291,9 +292,9 @@ describe("Service Hooks", async () => { it("verifies post-hooks are called and modify results", async () => { const service = new TestServiceWithHooks(config, database); const data = { - name: "PostTrackingTest", - latitude: 25, countryCode: "DE", + latitude: 25, + name: "PostTrackingTest", }; const result = await service.create(data); @@ -307,7 +308,7 @@ describe("Service Hooks", async () => { describe("Hook execution order", () => { it("executes pre-hook before and post-hook after processing", async () => { const service = new TestServiceWithHooks(config, database); - const data = { name: "OrderTest", latitude: 35, countryCode: "ES" }; + const data = { countryCode: "ES", latitude: 35, name: "OrderTest" }; const result = await service.create(data); @@ -323,9 +324,9 @@ describe("Service Hooks", async () => { // First create a record to update const createData = { - name: "ToBeUpdated", - latitude: 10, countryCode: "US", + latitude: 10, + name: "ToBeUpdated", }; const created = await service.create(createData); @@ -387,7 +388,7 @@ describe("Service Hooks", async () => { it("calls pre-hooks with data for write operations", async () => { const service = new TestServiceWithHooks(config, database); - const createData = { name: "WithData", latitude: 40, countryCode: "JP" }; + const createData = { countryCode: "JP", latitude: 40, name: "WithData" }; const created = await service.create(createData); const updateData = { name: "UpdatedWithData" }; @@ -411,7 +412,7 @@ describe("Service Hooks", async () => { } const service = new ErrorService(config, database); - const data = { name: "ErrorTest", latitude: 20, countryCode: "FR" }; + const data = { countryCode: "FR", latitude: 20, name: "ErrorTest" }; // Should throw the error from the pre-hook await expect(service.create(data)).rejects.toThrow("Pre-hook error"); @@ -429,7 +430,7 @@ describe("Service Hooks", async () => { } const service = new ErrorService(config, database); - const data = { name: "ErrorTest", latitude: 20, countryCode: "FR" }; + const data = { countryCode: "FR", latitude: 20, name: "ErrorTest" }; // Should throw the error from the post-hook await expect(service.create(data)).rejects.toThrow("Post-hook error"); diff --git a/packages/slonik/src/__test__/sql.test.ts b/packages/slonik/src/__test__/sql.test.ts index 3c4d6e167..9bedd21ee 100644 --- a/packages/slonik/src/__test__/sql.test.ts +++ b/packages/slonik/src/__test__/sql.test.ts @@ -1,6 +1,16 @@ -import { describe, it, expect } from "vitest"; +import { sql } from "slonik"; +import { describe, expect, it } from "vitest"; -import { isValueExpression } from "../sql"; +import { + createFilterFragment, + createLimitFragment, + createSortFragment, + createTableFragment, + createTableIdentifier, + createWhereFragment, + createWhereIdFragment, + isValueExpression, +} from "../sql"; describe("isValueExpression", () => { it("should return true for valid ValueExpression types", () => { @@ -38,3 +48,143 @@ describe("isValueExpression", () => { ).toBe(false); }); }); + +describe("createWhereIdFragment", () => { + it("returns a fragment containing WHERE id =", () => { + const fragment = createWhereIdFragment(1); + expect(fragment.sql).toMatch(/WHERE id = \$slonik_\d+/); + }); + + it("works with a numeric id", () => { + const fragment = createWhereIdFragment(42); + expect(fragment.values).toContain(42); + }); + + it("works with a string id", () => { + const fragment = createWhereIdFragment("abc-123"); + expect(fragment.values).toContain("abc-123"); + }); +}); + +describe("createFilterFragment", () => { + const tableIdentifier = createTableIdentifier("users"); + + it("returns an empty fragment when no filters provided", () => { + const fragment = createFilterFragment(undefined, tableIdentifier); + expect(fragment.sql.trim()).toBe(""); + }); + + it("returns a WHERE clause when filters are provided", () => { + const fragment = createFilterFragment( + { key: "name", operator: "eq", value: "alice" }, + tableIdentifier, + ); + expect(fragment.sql).toMatch(/WHERE/); + }); +}); + +describe("createLimitFragment", () => { + it("returns LIMIT n without offset", () => { + const fragment = createLimitFragment(10); + expect(fragment.sql).toContain("LIMIT"); + expect(fragment.values).toContain(10); + expect(fragment.sql).not.toContain("OFFSET"); + }); + + it("returns LIMIT n OFFSET m when offset is provided", () => { + const fragment = createLimitFragment(10, 20); + expect(fragment.sql).toContain("LIMIT"); + expect(fragment.sql).toContain("OFFSET"); + expect(fragment.values).toContain(10); + expect(fragment.values).toContain(20); + }); + + it("does not include OFFSET when offset is 0 (falsy)", () => { + const fragment = createLimitFragment(10, 0); + expect(fragment.sql).not.toContain("OFFSET"); + }); +}); + +describe("createTableFragment", () => { + it("returns unqualified identifier when no schema given", () => { + const fragment = createTableFragment("users"); + expect(fragment.sql).toMatch(/"users"/); + }); + + it("returns schema-qualified identifier when schema given", () => { + const fragment = createTableFragment("users", "tenant1"); + expect(fragment.sql).toMatch(/"tenant1"\."users"/); + }); +}); + +describe("createWhereFragment", () => { + const tableIdentifier = createTableIdentifier("users"); + + it("returns empty fragment when no filters and no extra fragments", () => { + const fragment = createWhereFragment(tableIdentifier); + expect(fragment.sql.trim()).toBe(""); + }); + + it("returns WHERE clause when filters are provided", () => { + const fragment = createWhereFragment(tableIdentifier, { + key: "name", + operator: "eq", + value: "alice", + }); + expect(fragment.sql).toMatch(/WHERE/i); + }); + + it("returns WHERE clause when only extra fragments are provided", () => { + const extra = sql.fragment`status = 'active'`; + const fragment = createWhereFragment(tableIdentifier, undefined, [extra]); + expect(fragment.sql).toMatch(/WHERE/i); + }); + + it("combines filters and extra fragments with AND", () => { + const extra = sql.fragment`status = 'active'`; + const fragment = createWhereFragment( + tableIdentifier, + { key: "name", operator: "eq", value: "alice" }, + [extra], + ); + expect(fragment.sql).toMatch(/AND/i); + }); + + it("strips leading WHERE keyword from extra fragments", () => { + const extraWithWhere = sql.fragment`WHERE status = 'active'`; + const fragment = createWhereFragment(tableIdentifier, undefined, [ + extraWithWhere, + ]); + // Should produce a single WHERE clause, not WHERE WHERE + const whereCount = (fragment.sql.match(/WHERE/gi) || []).length; + expect(whereCount).toBe(1); + }); +}); + +describe("createSortFragment", () => { + const tableIdentifier = createTableIdentifier("users"); + + it("returns an empty fragment when sort array is empty", () => { + const fragment = createSortFragment(tableIdentifier, []); + expect(fragment.sql.trim()).toBe(""); + }); + + it("returns an ORDER BY clause for a single sort entry", () => { + const fragment = createSortFragment(tableIdentifier, [ + { direction: "ASC", key: "name" }, + ]); + expect(fragment.sql).toMatch(/ORDER BY/); + }); + + it("returns DESC when direction is DESC", () => { + const fragment = createSortFragment(tableIdentifier, [ + { direction: "DESC", key: "id" }, + ]); + expect(fragment.sql).toContain("DESC"); + }); + + it("returns empty fragment when sort is undefined", () => { + const fragment = createSortFragment(tableIdentifier); + expect(fragment.sql.trim()).toBe(""); + }); +}); diff --git a/packages/slonik/src/__test__/sqlFactory.test.ts b/packages/slonik/src/__test__/sqlFactory.test.ts new file mode 100644 index 000000000..1fab38604 --- /dev/null +++ b/packages/slonik/src/__test__/sqlFactory.test.ts @@ -0,0 +1,133 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +import { newDb } from "pg-mem"; +import { describe, expect, it } from "vitest"; + +import type { Database } from "../types"; + +import DefaultSqlFactory from "../sqlFactory"; +import createConfig from "./helpers/createConfig"; +import createDatabase from "./helpers/createDatabase"; + +class HardDeleteFactory extends DefaultSqlFactory { + static readonly TABLE = "test"; +} + +class SoftDeleteFactory extends DefaultSqlFactory { + static readonly TABLE = "test"; + protected _softDeleteEnabled = true; +} + +const makeFactory = async ( + FactoryClass: typeof DefaultSqlFactory, + config: ApiConfig, + database: Database, +) => new FactoryClass(config, database); + +describe("DefaultSqlFactory — soft delete", async () => { + const db = newDb(); + db.public.none( + `create table test(id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name TEXT, deleted_at TIMESTAMP);`, + ); + const config = createConfig(); + const database = await createDatabase({ db }); + + it("getDeleteSql generates UPDATE SET deleted_at when soft-delete is enabled", async () => { + const factory = await makeFactory(SoftDeleteFactory, config, database); + const query = factory.getDeleteSql(1); + expect(query.sql).toMatch(/UPDATE/i); + expect(query.sql).toMatch(/deleted_at/i); + expect(query.sql).not.toMatch(/DELETE FROM/i); + }); + + it("getDeleteSql with force=true generates DELETE even when soft-delete is enabled", async () => { + const factory = await makeFactory(SoftDeleteFactory, config, database); + const query = factory.getDeleteSql(1, true); + expect(query.sql).toMatch(/DELETE FROM/i); + }); + + it("getDeleteSql generates DELETE when soft-delete is disabled", async () => { + const factory = await makeFactory(HardDeleteFactory, config, database); + const query = factory.getDeleteSql(1); + expect(query.sql).toMatch(/DELETE FROM/i); + }); + + it("getSoftDeleteFilterFragment returns empty fragment when soft-delete disabled", async () => { + const factory = await makeFactory(HardDeleteFactory, config, database); + const fragment = factory["getSoftDeleteFilterFragment"](true); + expect(fragment.sql.trim()).toBe(""); + }); + + it("getSoftDeleteFilterFragment returns WHERE clause when addWhere is true", async () => { + const factory = await makeFactory(SoftDeleteFactory, config, database); + const fragment = factory["getSoftDeleteFilterFragment"](true); + expect(fragment.sql).toMatch(/WHERE/i); + expect(fragment.sql).toMatch(/deleted_at IS NULL/); + }); + + it("getSoftDeleteFilterFragment returns AND clause when addWhere is false", async () => { + const factory = await makeFactory(SoftDeleteFactory, config, database); + const fragment = factory["getSoftDeleteFilterFragment"](false); + expect(fragment.sql).toMatch(/AND/i); + expect(fragment.sql).toMatch(/deleted_at IS NULL/); + }); +}); + +describe("DefaultSqlFactory — static defaults", () => { + it("LIMIT_DEFAULT is 20", () => { + expect(DefaultSqlFactory.LIMIT_DEFAULT).toBe(20); + }); + + it("LIMIT_MAX is 50", () => { + expect(DefaultSqlFactory.LIMIT_MAX).toBe(50); + }); + + it("SORT_DIRECTION is ASC", () => { + expect(DefaultSqlFactory.SORT_DIRECTION).toBe("ASC"); + }); + + it("SORT_KEY is id", () => { + expect(DefaultSqlFactory.SORT_KEY).toBe("id"); + }); +}); + +describe("DefaultSqlFactory — default schema", async () => { + const db = newDb(); + db.public.none( + `create table test(id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name TEXT);`, + ); + const config = createConfig(); + const database = await createDatabase({ db }); + + class SimpleFactory extends DefaultSqlFactory { + static readonly TABLE = "test"; + } + + it("schema is public when no schema passed to constructor", async () => { + const factory = await makeFactory(SimpleFactory, config, database); + expect(factory.schema).toBe("public"); + }); + + it("schema uses provided value when passed", async () => { + const factory = new SimpleFactory(config, database, "tenant1"); + expect(factory.schema).toBe("tenant1"); + }); +}); + +describe("DefaultSqlFactory — deprecated getTableFragment", async () => { + const db = newDb(); + db.public.none( + `create table test(id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name TEXT);`, + ); + const config = createConfig(); + const database = await createDatabase({ db }); + + class SimpleFactory extends DefaultSqlFactory { + static readonly TABLE = "test"; + } + + it("getTableFragment (deprecated) returns the same result as tableFragment getter", async () => { + const factory = await makeFactory(SimpleFactory, config, database); + expect(factory.getTableFragment().sql).toBe(factory.tableFragment.sql); + }); +}); diff --git a/packages/slonik/src/createDatabase.ts b/packages/slonik/src/createDatabase.ts index 42d00d31c..2ca3c1e0e 100644 --- a/packages/slonik/src/createDatabase.ts +++ b/packages/slonik/src/createDatabase.ts @@ -1,9 +1,10 @@ -import { createPool } from "slonik"; +import type { ClientConfiguration, DatabasePool } from "slonik"; -import createClientConfiguration from "./factories/createClientConfiguration"; +import { createPool } from "slonik"; import type { Database } from "./types"; -import type { ClientConfiguration, DatabasePool } from "slonik"; + +import createClientConfiguration from "./factories/createClientConfiguration"; const createDatabase = async ( connectionString: string, diff --git a/packages/slonik/src/factories/__test__/createClientConfiguration.test.ts b/packages/slonik/src/factories/__test__/createClientConfiguration.test.ts index b53763ed4..27dcbc5df 100644 --- a/packages/slonik/src/factories/__test__/createClientConfiguration.test.ts +++ b/packages/slonik/src/factories/__test__/createClientConfiguration.test.ts @@ -1,3 +1,5 @@ +import type { Query, QueryContext } from "slonik"; + /* istanbul ignore file */ import { createTypeParserPreset } from "slonik"; import { describe, expect, it } from "vitest"; @@ -7,8 +9,6 @@ import resultParser from "../../interceptors/resultParser"; import { createBigintTypeParser } from "../../typeParsers/createBigintTypeParser"; import createClientConfiguration from "../createClientConfiguration"; -import type { Query, QueryContext } from "slonik"; - describe("createClientConfiguration helper", () => { const defaultConfiguration = { captureStackTrace: false, @@ -43,4 +43,33 @@ describe("createClientConfiguration helper", () => { expect(configuration.interceptors).toContain(fieldNameCaseConverter); }); + + it("includes query logging interceptor when queryLoggingEnabled is true", () => { + const configuration = createClientConfiguration(undefined, true); + // The logging interceptor is the extra one beyond fieldNameCaseConverter + resultParser + expect(configuration.interceptors.length).toBeGreaterThan(2); + }); + + it("does not include query logging interceptor when queryLoggingEnabled is false", () => { + const configuration = createClientConfiguration(undefined, false); + expect(configuration.interceptors).toHaveLength(2); + }); + + it("does not include query logging interceptor when queryLoggingEnabled is undefined", () => { + const configuration = createClientConfiguration(); + expect(configuration.interceptors).toHaveLength(2); + }); + + it("appends user interceptors after built-in interceptors", () => { + const userInterceptor = { + transformRow: (_context: unknown, _query: unknown, row: unknown) => row, + }; + const configuration = createClientConfiguration({ + interceptors: [userInterceptor as never], + }); + // built-ins come first, user interceptor is last + expect(configuration.interceptors[0]).toBe(fieldNameCaseConverter); + expect(configuration.interceptors[1]).toBe(resultParser); + expect(configuration.interceptors.at(-1)).toBe(userInterceptor); + }); }); diff --git a/packages/slonik/src/factories/createClientConfiguration.ts b/packages/slonik/src/factories/createClientConfiguration.ts index 7be82eb96..3ef56e146 100644 --- a/packages/slonik/src/factories/createClientConfiguration.ts +++ b/packages/slonik/src/factories/createClientConfiguration.ts @@ -1,3 +1,5 @@ +import type { ClientConfigurationInput } from "slonik"; + import { createTypeParserPreset } from "slonik"; import { createQueryLoggingInterceptor } from "slonik-interceptor-query-logging"; @@ -5,8 +7,6 @@ import fieldNameCaseConverter from "../interceptors/fieldNameCaseConverter"; import resultParser from "../interceptors/resultParser"; import { createBigintTypeParser } from "../typeParsers/createBigintTypeParser"; -import type { ClientConfigurationInput } from "slonik"; - const createClientConfiguration = ( config?: ClientConfigurationInput, queryLoggingEnabled?: boolean, diff --git a/packages/slonik/src/filters.ts b/packages/slonik/src/filters.ts index 6450ddada..1f2689e77 100644 --- a/packages/slonik/src/filters.ts +++ b/packages/slonik/src/filters.ts @@ -1,8 +1,9 @@ +import type { FragmentSqlToken, IdentifierSqlToken } from "slonik"; + import humps from "humps"; import { sql } from "slonik"; import type { BaseFilterInput, FilterInput } from "./types"; -import type { IdentifierSqlToken, FragmentSqlToken } from "slonik"; const applyFilter = ( tableIdentifier: IdentifierSqlToken, @@ -43,9 +44,24 @@ const applyFilter = ( } switch (operator) { + case "bt": { + const [start, end] = value.split(","); + + if (!start || !end) { + throw new Error("BETWEEN operator requires exactly two values"); + } + + clauseOperator = not ? sql.fragment`NOT BETWEEN` : sql.fragment`BETWEEN`; + + value = insensitive + ? sql.fragment`unaccent(lower(${start})) AND unaccent(lower(${end}))` + : sql.fragment`${start} AND ${end}`; + + break; + } case "ct": - case "sw": - case "ew": { + case "ew": + case "sw": { const valueString = { ct: `%${value}%`, // contains ew: `%${value}`, // ends with @@ -60,16 +76,6 @@ const applyFilter = ( break; } - case "eq": - default: { - clauseOperator = not ? sql.fragment`!=` : sql.fragment`=`; - - if (insensitive) { - value = sql.fragment`unaccent(lower(${value}))`; - } - - break; - } case "gt": { clauseOperator = not ? sql.fragment`<` : sql.fragment`>`; @@ -88,24 +94,6 @@ const applyFilter = ( break; } - case "lte": { - clauseOperator = not ? sql.fragment`>` : sql.fragment`<=`; - - if (insensitive) { - value = sql.fragment`unaccent(lower(${value}))`; - } - - break; - } - case "lt": { - clauseOperator = not ? sql.fragment`>` : sql.fragment`<`; - - if (insensitive) { - value = sql.fragment`unaccent(lower(${value}))`; - } - - break; - } case "in": { const values = value.split(",").filter(Boolean); @@ -124,18 +112,30 @@ const applyFilter = ( break; } - case "bt": { - const [start, end] = value.split(","); + case "lt": { + clauseOperator = not ? sql.fragment`>` : sql.fragment`<`; - if (!start || !end) { - throw new Error("BETWEEN operator requires exactly two values"); + if (insensitive) { + value = sql.fragment`unaccent(lower(${value}))`; } - clauseOperator = not ? sql.fragment`NOT BETWEEN` : sql.fragment`BETWEEN`; + break; + } + case "lte": { + clauseOperator = not ? sql.fragment`>` : sql.fragment`<=`; - value = insensitive - ? sql.fragment`unaccent(lower(${start})) AND unaccent(lower(${end}))` - : sql.fragment`${start} AND ${end}`; + if (insensitive) { + value = sql.fragment`unaccent(lower(${value}))`; + } + + break; + } + default: { + clauseOperator = not ? sql.fragment`!=` : sql.fragment`=`; + + if (insensitive) { + value = sql.fragment`unaccent(lower(${value}))`; + } break; } diff --git a/packages/slonik/src/index.ts b/packages/slonik/src/index.ts index 95402abcb..686e4ddfe 100644 --- a/packages/slonik/src/index.ts +++ b/packages/slonik/src/index.ts @@ -1,7 +1,8 @@ +import type { ConnectionRoutine, DatabasePool, QueryFunction } from "slonik"; + import { sql } from "slonik"; import type { SlonikConfig } from "./types"; -import type { ConnectionRoutine, DatabasePool, QueryFunction } from "slonik"; declare module "fastify" { interface FastifyInstance { @@ -30,16 +31,16 @@ declare module "@prefabs.tech/fastify-config" { } } -export { default } from "./plugin"; +export { default as createDatabase } from "./createDatabase"; export * from "./filters"; -export * from "./sql"; +export { default as formatDate } from "./formatDate"; -export { createBigintTypeParser } from "./typeParsers/createBigintTypeParser"; -export { default as createDatabase } from "./createDatabase"; +export { default as migrationPlugin } from "./migrationPlugin"; +export { default } from "./plugin"; export { default as BaseService } from "./service"; +export * from "./sql"; export { default as DefaultSqlFactory } from "./sqlFactory"; -export { default as formatDate } from "./formatDate"; -export { default as migrationPlugin } from "./migrationPlugin"; +export { createBigintTypeParser } from "./typeParsers/createBigintTypeParser"; export type * from "./types"; diff --git a/packages/slonik/src/interceptors/__test__/resultParser.test.ts b/packages/slonik/src/interceptors/__test__/resultParser.test.ts new file mode 100644 index 000000000..22af65b94 --- /dev/null +++ b/packages/slonik/src/interceptors/__test__/resultParser.test.ts @@ -0,0 +1,60 @@ +import { SchemaValidationError } from "slonik"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import resultParser from "../resultParser"; +import createQueryContext from "./helpers/createQueryContext"; + +const fakeQuery = { sql: "SELECT 1", values: [] }; +const fakeFields = [{ dataTypeId: 1, name: "id" }]; + +const contextWithParser = (schema: z.ZodTypeAny) => ({ + ...createQueryContext(), + resultParser: schema, +}); + +describe("resultParser interceptor", () => { + const { transformRow } = resultParser; + + if (!transformRow) throw new Error("transformRow must be defined"); + + it("returns row unchanged when queryContext has no resultParser", () => { + const row = { id: 1, name: "Alice" }; + const result = transformRow( + createQueryContext(), + fakeQuery, + row, + fakeFields, + ); + expect(result).toBe(row); + }); + + it("returns parsed data when zod schema passes validation", () => { + const schema = z.object({ id: z.number(), name: z.string() }); + const row = { id: 1, name: "Alice" }; + const result = transformRow( + contextWithParser(schema), + fakeQuery, + row, + fakeFields, + ); + expect(result).toEqual({ id: 1, name: "Alice" }); + }); + + it("throws SchemaValidationError when zod schema fails validation", () => { + const schema = z.object({ id: z.number() }); + const row = { id: "not-a-number" }; + + expect(() => + transformRow(contextWithParser(schema), fakeQuery, row, fakeFields), + ).toThrow(SchemaValidationError); + }); + + it("does not mutate the original row when validation passes", () => { + const schema = z.object({ id: z.number() }); + const row = { id: 1 }; + const original = { ...row }; + transformRow(contextWithParser(schema), fakeQuery, row, fakeFields); + expect(row).toEqual(original); + }); +}); diff --git a/packages/slonik/src/interceptors/fieldNameCaseConverter.ts b/packages/slonik/src/interceptors/fieldNameCaseConverter.ts index 944a5b266..2351cbce3 100644 --- a/packages/slonik/src/interceptors/fieldNameCaseConverter.ts +++ b/packages/slonik/src/interceptors/fieldNameCaseConverter.ts @@ -1,5 +1,3 @@ -import humps from "humps"; - import type { Field, Interceptor, @@ -8,6 +6,8 @@ import type { QueryResultRow, } from "slonik"; +import humps from "humps"; + const fieldNameCaseConverter: Interceptor = { transformRow: ( /* eslint-disable @typescript-eslint/no-unused-vars */ diff --git a/packages/slonik/src/interceptors/resultParser.ts b/packages/slonik/src/interceptors/resultParser.ts index adbde7547..27d01298c 100644 --- a/packages/slonik/src/interceptors/resultParser.ts +++ b/packages/slonik/src/interceptors/resultParser.ts @@ -1,13 +1,13 @@ -import { SchemaValidationError } from "slonik"; - import type { Field, Interceptor, - QueryResultRow, Query, QueryContext, + QueryResultRow, } from "slonik"; +import { SchemaValidationError } from "slonik"; + const createResultParser: Interceptor = { // If you are not going to transform results using Zod, then you should use `afterQueryExecution` instead. // Future versions of Zod will provide a more efficient parser when parsing without transformations. diff --git a/packages/slonik/src/migrate.ts b/packages/slonik/src/migrate.ts index b533701dc..54dd1dabe 100644 --- a/packages/slonik/src/migrate.ts +++ b/packages/slonik/src/migrate.ts @@ -1,8 +1,9 @@ +import type { ClientConfig } from "pg"; + import { migrate as runMigrations } from "@prefabs.tech/postgres-migrations"; import * as pg from "pg"; import type { SlonikOptions } from "./types"; -import type { ClientConfig } from "pg"; const migrate = async (slonikOptions: SlonikOptions) => { const defaultMigrationsPath = "migrations"; diff --git a/packages/slonik/src/migrationPlugin.ts b/packages/slonik/src/migrationPlugin.ts index d16c211f2..321d84a02 100644 --- a/packages/slonik/src/migrationPlugin.ts +++ b/packages/slonik/src/migrationPlugin.ts @@ -1,9 +1,10 @@ -import FastifyPlugin from "fastify-plugin"; +import type { FastifyInstance } from "fastify"; -import migrate from "./migrate"; +import FastifyPlugin from "fastify-plugin"; import type { SlonikOptions } from "./types"; -import type { FastifyInstance } from "fastify"; + +import migrate from "./migrate"; const plugin = async (fastify: FastifyInstance, options: SlonikOptions) => { fastify.log.info("Running database migrations"); diff --git a/packages/slonik/src/migrations/__test__/queryToCreateExtensions.test.ts b/packages/slonik/src/migrations/__test__/queryToCreateExtensions.test.ts new file mode 100644 index 000000000..84a12cc48 --- /dev/null +++ b/packages/slonik/src/migrations/__test__/queryToCreateExtensions.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import queryToCreateExtension from "../queryToCreateExtensions"; + +describe("queryToCreateExtension", () => { + it("returns an object with a sql string", () => { + const result = queryToCreateExtension("citext"); + expect(typeof result.sql).toBe("string"); + }); + + it("generated SQL contains CREATE EXTENSION IF NOT EXISTS", () => { + const result = queryToCreateExtension("citext"); + expect(result.sql).toMatch(/CREATE EXTENSION IF NOT EXISTS/i); + }); + + it("generated SQL references the provided extension name as an identifier", () => { + const result = queryToCreateExtension("unaccent"); + expect(result.sql).toContain('"unaccent"'); + }); + + it("works for any extension name", () => { + const result = queryToCreateExtension("pgcrypto"); + expect(result.sql).toContain('"pgcrypto"'); + }); +}); diff --git a/packages/slonik/src/migrations/__test__/runMigrations.test.ts b/packages/slonik/src/migrations/__test__/runMigrations.test.ts new file mode 100644 index 000000000..056d2ae35 --- /dev/null +++ b/packages/slonik/src/migrations/__test__/runMigrations.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Database, SlonikOptions } from "../../types"; + +import { EXTENSIONS } from "../../constants"; +import runMigrations from "../runMigrations"; + +const makeDatabase = () => { + const queryMock = vi.fn().mockResolvedValue({ rows: [] }); + const connectMock = vi.fn().mockImplementation(async (routine) => { + const connection = { query: queryMock }; + return routine(connection); + }); + + return { + connectMock, + database: { connect: connectMock } as unknown as Database, + queryMock, + }; +}; + +const baseOptions: SlonikOptions = { + db: { + databaseName: "test", + host: "localhost", + password: "pass", + username: "user", + }, +}; + +describe("runMigrations", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls database.connect once", async () => { + const { connectMock, database } = makeDatabase(); + await runMigrations(database, baseOptions); + expect(connectMock).toHaveBeenCalledTimes(1); + }); + + it("creates an extension for each default extension", async () => { + const { database, queryMock } = makeDatabase(); + await runMigrations(database, baseOptions); + expect(queryMock).toHaveBeenCalledTimes(EXTENSIONS.length); + }); + + it("includes citext and unaccent by default", async () => { + const { database, queryMock } = makeDatabase(); + await runMigrations(database, baseOptions); + + const sqls = queryMock.mock.calls.map((call) => call[0].sql as string); + expect(sqls.some((s) => s.includes('"citext"'))).toBe(true); + expect(sqls.some((s) => s.includes('"unaccent"'))).toBe(true); + }); + + it("merges custom extensions with defaults", async () => { + const { database, queryMock } = makeDatabase(); + await runMigrations(database, { + ...baseOptions, + extensions: ["pgcrypto"], + }); + + const sqls = queryMock.mock.calls.map((call) => call[0].sql as string); + expect(sqls.some((s) => s.includes('"pgcrypto"'))).toBe(true); + expect(sqls.some((s) => s.includes('"citext"'))).toBe(true); + }); + + it("deduplicates extensions when custom overlaps with defaults", async () => { + const { database, queryMock } = makeDatabase(); + await runMigrations(database, { + ...baseOptions, + extensions: ["citext", "pgcrypto"], // "citext" is already in EXTENSIONS + }); + + const sqls = queryMock.mock.calls.map((call) => call[0].sql as string); + const citextCalls = sqls.filter((s) => s.includes('"citext"')); + expect(citextCalls).toHaveLength(1); + }); + + it("calls connection.query for each unique extension", async () => { + const { database, queryMock } = makeDatabase(); + const extraExtensions = ["pgcrypto", "uuid-ossp"]; + await runMigrations(database, { + ...baseOptions, + extensions: extraExtensions, + }); + + const expectedCount = new Set([...EXTENSIONS, ...extraExtensions]).size; + expect(queryMock).toHaveBeenCalledTimes(expectedCount); + }); +}); diff --git a/packages/slonik/src/migrations/queryToCreateExtensions.ts b/packages/slonik/src/migrations/queryToCreateExtensions.ts index 14c54f4fb..1a525ced1 100644 --- a/packages/slonik/src/migrations/queryToCreateExtensions.ts +++ b/packages/slonik/src/migrations/queryToCreateExtensions.ts @@ -1,8 +1,8 @@ -import { sql } from "slonik"; - import type { QuerySqlToken } from "slonik"; import type { ZodTypeAny } from "zod"; +import { sql } from "slonik"; + const queryToCreateExtension = ( extension: string, ): QuerySqlToken => { diff --git a/packages/slonik/src/migrations/runMigrations.ts b/packages/slonik/src/migrations/runMigrations.ts index 675b2aec2..21fd347f5 100644 --- a/packages/slonik/src/migrations/runMigrations.ts +++ b/packages/slonik/src/migrations/runMigrations.ts @@ -1,8 +1,8 @@ -import queryToCreateExtension from "./queryToCreateExtensions"; -import { EXTENSIONS } from "../constants"; - import type { Database, SlonikOptions } from "../types"; +import { EXTENSIONS } from "../constants"; +import queryToCreateExtension from "./queryToCreateExtensions"; + const runMigrations = async (database: Database, options: SlonikOptions) => { const extensions = [ ...new Set([...EXTENSIONS, ...(options.extensions || [])]), diff --git a/packages/slonik/src/plugin.ts b/packages/slonik/src/plugin.ts index 1f066bb68..6cf009a8c 100644 --- a/packages/slonik/src/plugin.ts +++ b/packages/slonik/src/plugin.ts @@ -1,13 +1,14 @@ +import type { FastifyInstance } from "fastify"; + import FastifyPlugin from "fastify-plugin"; import { stringifyDsn } from "slonik"; +import type { SlonikOptions } from "./types"; + import createClientConfiguration from "./factories/createClientConfiguration"; import runMigrations from "./migrations/runMigrations"; import { fastifySlonik } from "./slonik"; -import type { SlonikOptions } from "./types"; -import type { FastifyInstance } from "fastify"; - const plugin = async (fastify: FastifyInstance, options: SlonikOptions) => { fastify.log.info("Registering fastify-slonik plugin"); @@ -26,11 +27,11 @@ const plugin = async (fastify: FastifyInstance, options: SlonikOptions) => { } await fastify.register(fastifySlonik, { - connectionString: stringifyDsn(options.db), clientConfiguration: createClientConfiguration( options.clientConfiguration, options.queryLogging?.enabled, ), + connectionString: stringifyDsn(options.db), }); await runMigrations(fastify.slonik, options); diff --git a/packages/slonik/src/service.ts b/packages/slonik/src/service.ts index fd1be672a..555bbb41f 100644 --- a/packages/slonik/src/service.ts +++ b/packages/slonik/src/service.ts @@ -1,4 +1,4 @@ -import DefaultSqlFactory from "./sqlFactory"; +import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Database, @@ -8,19 +8,49 @@ import type { SqlFactory, } from "./types"; import type { PaginatedList } from "./types/service"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +import DefaultSqlFactory from "./sqlFactory"; abstract class BaseService< T, C extends Record, U extends Record, > implements Service { + get config(): ApiConfig { + return this._config; + } + get database(): Database { + return this._database; + } + get factory(): SqlFactory { + if (!this._factory) { + const sqlFactoryClass = this.sqlFactoryClass; + + this._factory = new sqlFactoryClass( + this.config, + this.database, + this.schema, + ); + } + + return this._factory; + } + get schema(): string { + return this._schema || "public"; + } + + get sqlFactoryClass() { + return DefaultSqlFactory; + } + + get table(): string { + return this.factory.table; + } /* eslint-enabled */ protected _config: ApiConfig; protected _database: Database; protected _factory: SqlFactory | undefined; protected _schema = "public"; - constructor(config: ApiConfig, database: Database, schema?: string) { this._config = config; this._database = database; @@ -29,31 +59,6 @@ abstract class BaseService< this._schema = schema; } } - - // Type-safe optional hook methods for pre-processing - protected preAll?(): Promise; - protected preCount?(): Promise; - protected preCreate?(data: C): Promise; - protected preDelete?(id: number | string): Promise; - protected preFind?(): Promise; - protected preFindById?(id: number | string): Promise; - protected preFindOne?(): Promise; - protected preList?(): Promise; - protected preUpdate?(data: U): Promise; - - // Type-safe optional hook methods for post-processing - protected postAll?( - result: Partial, - ): Promise>; - protected postCount?(result: number): Promise; - // protected postCreate?(result: T): Promise; - protected postDelete?(result: T): Promise; - protected postFind?(result: readonly T[]): Promise; - protected postFindById?(result: T): Promise; - protected postFindOne?(result: T): Promise; - protected postList?(result: PaginatedList): Promise>; - protected postUpdate?(result: T): Promise; - /** * Only for entities that support it. Returns the full list of entities, * with no filtering, no custom sorting order, no pagination, @@ -74,7 +79,6 @@ abstract class BaseService< return await this.postProcess>("all", result); } - async count(filters?: FilterInput): Promise { await this.preProcess("count"); @@ -88,7 +92,6 @@ abstract class BaseService< return await this.postProcess("count", count); } - async create(data: C): Promise { const processedData = await this.preProcess("create", data); @@ -103,7 +106,7 @@ abstract class BaseService< return result ? await this.postProcess("create", result) : undefined; } - async delete(id: number | string, force?: boolean): Promise { + async delete(id: number | string, force?: boolean): Promise { await this.preProcess("delete", id); const query = this.factory.getDeleteSql(id, force); @@ -114,7 +117,6 @@ abstract class BaseService< return result ? await this.postProcess("delete", result) : result; } - async find(filters?: FilterInput, sort?: SortInput[]): Promise { await this.preProcess("find"); @@ -126,33 +128,30 @@ abstract class BaseService< return await this.postProcess("find", result); } - - async findById(id: number | string): Promise { + async findById(id: number | string): Promise { await this.preProcess("findById"); const query = this.factory.getFindByIdSql(id); const result = (await this.database.connect((connection) => { return connection.maybeOne(query); - })) as T | null; + })) as null | T; // eslint-disable-next-line unicorn/no-null return result ? await this.postProcess("findById", result) : null; } - - async findOne(filters?: FilterInput, sort?: SortInput[]): Promise { + async findOne(filters?: FilterInput, sort?: SortInput[]): Promise { await this.preProcess("findOne"); const query = this.factory.getFindOneSql(filters, sort); const result = (await this.database.connect((connection) => { return connection.maybeOne(query); - })) as T | null; + })) as null | T; // eslint-disable-next-line unicorn/no-null return result ? await this.postProcess("findOne", result) : null; } - async list( limit?: number, offset?: number, @@ -172,14 +171,13 @@ abstract class BaseService< ]); const result = { - totalCount, - filteredCount, data: data as readonly T[], + filteredCount, + totalCount, }; return await this.postProcess>("list", result); } - async update(id: number | string, data: U): Promise { const processedData = await this.preProcess("update", data); @@ -193,47 +191,11 @@ abstract class BaseService< return await this.postProcess("update", result); } - - get config(): ApiConfig { - return this._config; - } - - get database(): Database { - return this._database; - } - - get factory(): SqlFactory { - if (!this._factory) { - const sqlFactoryClass = this.sqlFactoryClass; - - this._factory = new sqlFactoryClass( - this.config, - this.database, - this.schema, - ); - } - - return this._factory; - } - - get schema(): string { - return this._schema || "public"; - } - - get sqlFactoryClass() { - return DefaultSqlFactory; - } - - get table(): string { - return this.factory.table; - } - protected getHook(prefix: string, action: string): unknown { const hookName = `${prefix}${action.charAt(0).toUpperCase()}${action.slice(1)}`; return (this as Record)[hookName]; } - protected isCompatibleType(processed: T, original: T): boolean { // If original is undefined, processed can be anything if (original === undefined) { @@ -265,33 +227,28 @@ abstract class BaseService< return true; } - protected async preProcess( - action: string, - data?: D, - ): Promise { - const preHook = this.getHook("pre", action); - - if (typeof preHook === "function") { - const processedData = await ( - preHook as (data?: D) => Promise - ).call(this, data); - - // Validate that the processed data has compatible type with original data - if ( - processedData !== undefined && - this.isCompatibleType(processedData, data) - ) { - return processedData; - } - } + // Type-safe optional hook methods for post-processing + protected postAll?( + result: Partial, + ): Promise>; - return data; - } + protected postCount?(result: number): Promise; protected async postCreate(result: T): Promise { return result; } + // protected postCreate?(result: T): Promise; + protected postDelete?(result: T): Promise; + + protected postFind?(result: readonly T[]): Promise; + + protected postFindById?(result: T): Promise; + + protected postFindOne?(result: T): Promise; + + protected postList?(result: PaginatedList): Promise>; + protected async postProcess(action: string, result: R): Promise { const postHook = this.getHook("post", action); @@ -311,6 +268,50 @@ abstract class BaseService< return result; } + + protected postUpdate?(result: T): Promise; + + // Type-safe optional hook methods for pre-processing + protected preAll?(): Promise; + + protected preCount?(): Promise; + + protected preCreate?(data: C): Promise; + + protected preDelete?(id: number | string): Promise; + + protected preFind?(): Promise; + + protected preFindById?(id: number | string): Promise; + + protected preFindOne?(): Promise; + + protected preList?(): Promise; + + protected async preProcess( + action: string, + data?: D, + ): Promise { + const preHook = this.getHook("pre", action); + + if (typeof preHook === "function") { + const processedData = await ( + preHook as (data?: D) => Promise + ).call(this, data); + + // Validate that the processed data has compatible type with original data + if ( + processedData !== undefined && + this.isCompatibleType(processedData, data) + ) { + return processedData; + } + } + + return data; + } + + protected preUpdate?(data: U): Promise; } export default BaseService; diff --git a/packages/slonik/src/slonik.ts b/packages/slonik/src/slonik.ts index f78e1a111..9fc5c79ef 100644 --- a/packages/slonik/src/slonik.ts +++ b/packages/slonik/src/slonik.ts @@ -1,20 +1,21 @@ +import type { FastifyInstance } from "fastify"; +import type { ClientConfigurationInput } from "slonik"; + // [OP 2023-JAN-28] Copy/pasted from https://github.com/spa5k/fastify-slonik/blob/main/src/index.ts import fastifyPlugin from "fastify-plugin"; import { sql } from "slonik"; -import createDatabase from "./createDatabase"; - import type { Database } from "./types"; -import type { FastifyInstance } from "fastify"; -import type { ClientConfigurationInput } from "slonik"; + +import createDatabase from "./createDatabase"; type SlonikOptions = { - connectionString: string; clientConfiguration?: ClientConfigurationInput; + connectionString: string; }; const plugin = async (fastify: FastifyInstance, options: SlonikOptions) => { - const { connectionString, clientConfiguration } = options; + const { clientConfiguration, connectionString } = options; let db: Database; try { diff --git a/packages/slonik/src/sql.ts b/packages/slonik/src/sql.ts index 23ddd06a0..eda04bc6c 100644 --- a/packages/slonik/src/sql.ts +++ b/packages/slonik/src/sql.ts @@ -1,15 +1,16 @@ -import humps from "humps"; -import { sql } from "slonik"; - -import { applyFiltersToQuery, buildFilterFragment } from "./filters"; - -import type { FilterInput, SortInput } from "./types"; import type { FragmentSqlToken, IdentifierSqlToken, ValueExpression, } from "slonik"; +import humps from "humps"; +import { sql } from "slonik"; + +import type { FilterInput, SortInput } from "./types"; + +import { applyFiltersToQuery, buildFilterFragment } from "./filters"; + const createFilterFragment = ( filters: FilterInput | undefined, tableIdentifier: IdentifierSqlToken, diff --git a/packages/slonik/src/sqlFactory.ts b/packages/slonik/src/sqlFactory.ts index 4bab9f58f..7ffa1237e 100644 --- a/packages/slonik/src/sqlFactory.ts +++ b/packages/slonik/src/sqlFactory.ts @@ -1,7 +1,22 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { + FragmentSqlToken, + IdentifierSqlToken, + QuerySqlToken, +} from "slonik"; + import humps from "humps"; import { sql } from "slonik"; import { z } from "zod"; +import type { + Database, + FilterInput, + SortDirection, + SortInput, + SqlFactory, +} from "./types"; + import { createFilterFragment, createLimitFragment, @@ -12,34 +27,74 @@ import { isValueExpression, } from "./sql"; -import type { - Database, - FilterInput, - SqlFactory, - SortInput, - SortDirection, -} from "./types"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; -import type { - FragmentSqlToken, - IdentifierSqlToken, - QuerySqlToken, -} from "slonik"; - class DefaultSqlFactory implements SqlFactory { - static readonly TABLE = undefined as unknown as string; static readonly LIMIT_DEFAULT: number = 20; static readonly LIMIT_MAX: number = 50; static readonly SORT_DIRECTION: SortDirection = "ASC"; static readonly SORT_KEY: string = "id"; + static readonly TABLE = undefined as unknown as string; + + get config(): ApiConfig { + return this._config; + } + get database(): Database { + return this._database; + } + get limitDefault(): number { + return ( + this.config.slonik?.pagination?.defaultLimit || + (this.constructor as typeof DefaultSqlFactory).LIMIT_DEFAULT + ); + } + get limitMax(): number { + return ( + this.config.slonik?.pagination?.maxLimit || + (this.constructor as typeof DefaultSqlFactory).LIMIT_MAX + ); + } + get schema(): string { + return this._schema || "public"; + } + get softDeleteEnabled(): boolean { + return this._softDeleteEnabled; + } + + get sortDirection(): SortDirection { + return (this.constructor as typeof DefaultSqlFactory).SORT_DIRECTION; + } + + get sortKey(): string { + return (this.constructor as typeof DefaultSqlFactory).SORT_KEY; + } + + get table(): string { + return (this.constructor as typeof DefaultSqlFactory).TABLE; + } + + get tableFragment(): FragmentSqlToken { + return createTableFragment(this.table, this.schema); + } + + get tableIdentifier(): IdentifierSqlToken { + return createTableIdentifier(this.table); + } + + get validationSchema(): z.ZodTypeAny { + return this._validationSchema || z.any(); + } protected _config: ApiConfig; + protected _database: Database; + protected _factory: SqlFactory | undefined; + protected _schema = "public"; - protected _validationSchema: z.ZodTypeAny = z.any(); + protected _softDeleteEnabled: boolean = false; + protected _validationSchema: z.ZodTypeAny = z.any(); + constructor(config: ApiConfig, database: Database, schema?: string) { this._config = config; this._database = database; @@ -200,92 +255,10 @@ class DefaultSqlFactory implements SqlFactory { `; } - get config(): ApiConfig { - return this._config; - } - - get database(): Database { - return this._database; - } - - get limitDefault(): number { - return ( - this.config.slonik?.pagination?.defaultLimit || - (this.constructor as typeof DefaultSqlFactory).LIMIT_DEFAULT - ); - } - - get limitMax(): number { - return ( - this.config.slonik?.pagination?.maxLimit || - (this.constructor as typeof DefaultSqlFactory).LIMIT_MAX - ); - } - - get schema(): string { - return this._schema || "public"; - } - - get sortDirection(): SortDirection { - return (this.constructor as typeof DefaultSqlFactory).SORT_DIRECTION; - } - - get sortKey(): string { - return (this.constructor as typeof DefaultSqlFactory).SORT_KEY; - } - - get table(): string { - return (this.constructor as typeof DefaultSqlFactory).TABLE; - } - - get tableFragment(): FragmentSqlToken { - return createTableFragment(this.table, this.schema); - } - - get tableIdentifier(): IdentifierSqlToken { - return createTableIdentifier(this.table); - } - - get validationSchema(): z.ZodTypeAny { - return this._validationSchema || z.any(); - } - - get softDeleteEnabled(): boolean { - return this._softDeleteEnabled; - } - protected getAdditionalFilterFragments(): FragmentSqlToken[] { return []; } - protected getWhereFragment(options?: { - filters?: FilterInput; - filterFragment?: FragmentSqlToken; - includeSoftDelete?: boolean; - tableIdentifier?: IdentifierSqlToken; - }): FragmentSqlToken { - const { - filters, - includeSoftDelete = true, - filterFragment, - tableIdentifier = this.tableIdentifier, - } = options || {}; - - const fragments: FragmentSqlToken[] = []; - - if (filterFragment) { - fragments.push(filterFragment); - } - - if (includeSoftDelete && this.softDeleteEnabled) { - fragments.push(sql.fragment`${this.tableIdentifier}.deleted_at IS NULL`); - } - - fragments.push(...this.getAdditionalFilterFragments()); - - return this.getCreateWhereFragment(tableIdentifier, filters, fragments); - } - protected getCreateWhereFragment( tableIdentifier: IdentifierSqlToken, filters: FilterInput | undefined, @@ -323,12 +296,40 @@ class DefaultSqlFactory implements SqlFactory { return ( sort || [ { - key: this.sortKey, direction: this.sortDirection, + key: this.sortKey, }, ] ); } + + protected getWhereFragment(options?: { + filterFragment?: FragmentSqlToken; + filters?: FilterInput; + includeSoftDelete?: boolean; + tableIdentifier?: IdentifierSqlToken; + }): FragmentSqlToken { + const { + filterFragment, + filters, + includeSoftDelete = true, + tableIdentifier = this.tableIdentifier, + } = options || {}; + + const fragments: FragmentSqlToken[] = []; + + if (filterFragment) { + fragments.push(filterFragment); + } + + if (includeSoftDelete && this.softDeleteEnabled) { + fragments.push(sql.fragment`${this.tableIdentifier}.deleted_at IS NULL`); + } + + fragments.push(...this.getAdditionalFilterFragments()); + + return this.getCreateWhereFragment(tableIdentifier, filters, fragments); + } } export default DefaultSqlFactory; diff --git a/packages/slonik/src/typeParsers/__test__/createBigintTypeParser.test.ts b/packages/slonik/src/typeParsers/__test__/createBigintTypeParser.test.ts new file mode 100644 index 000000000..36dacf487 --- /dev/null +++ b/packages/slonik/src/typeParsers/__test__/createBigintTypeParser.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { createBigintTypeParser } from "../createBigintTypeParser"; + +describe("createBigintTypeParser", () => { + it("returns a type parser with name int8", () => { + const parser = createBigintTypeParser(); + expect(parser.name).toBe("int8"); + }); + + it("parse converts a bigint string to a number", () => { + const { parse } = createBigintTypeParser(); + expect(parse("42")).toBe(42); + }); + + it("parse handles large-but-safe integer strings", () => { + const { parse } = createBigintTypeParser(); + expect(parse(String(Number.MAX_SAFE_INTEGER))).toBe( + Number.MAX_SAFE_INTEGER, + ); + }); + + it("parse returns an integer, not a float", () => { + const { parse } = createBigintTypeParser(); + const result = parse("100"); + expect(Number.isInteger(result)).toBe(true); + }); +}); diff --git a/packages/slonik/src/types/config.ts b/packages/slonik/src/types/config.ts index f08da9ea2..51a546032 100644 --- a/packages/slonik/src/types/config.ts +++ b/packages/slonik/src/types/config.ts @@ -1,5 +1,7 @@ import type { ClientConfigurationInput, ConnectionOptions } from "slonik"; +type SlonikConfig = SlonikOptions; + type SlonikOptions = { clientConfiguration?: ClientConfigurationInput; db: ConnectionOptions; @@ -16,6 +18,4 @@ type SlonikOptions = { }; }; -type SlonikConfig = SlonikOptions; - export type { SlonikConfig, SlonikOptions }; diff --git a/packages/slonik/src/types/database.ts b/packages/slonik/src/types/database.ts index 723166a20..c15279d59 100644 --- a/packages/slonik/src/types/database.ts +++ b/packages/slonik/src/types/database.ts @@ -1,47 +1,47 @@ import type { ConnectionRoutine, DatabasePool, QueryFunction } from "slonik"; +type BaseFilterInput = { + insensitive?: boolean | string; + key: string; + not?: boolean | string; + operator: operator; + value: string; +}; + type Database = { connect: (connectionRoutine: ConnectionRoutine) => Promise; pool: DatabasePool; query: QueryFunction; }; +type FilterInput = + | { + AND: FilterInput[]; + } + | { + OR: FilterInput[]; + } + | BaseFilterInput; + type operator = + | "bt" | "ct" | "dwithin" - | "sw" - | "ew" | "eq" + | "ew" | "gt" | "gte" - | "lte" - | "lt" | "in" - | "bt"; - -type BaseFilterInput = { - key: string; - operator: operator; - not?: boolean | string; - value: string; - insensitive?: boolean | string; -}; - -type FilterInput = - | BaseFilterInput - | { - AND: FilterInput[]; - } - | { - OR: FilterInput[]; - }; + | "lt" + | "lte" + | "sw"; type SortDirection = "ASC" | "DESC"; type SortInput = { - key: string; direction: SortDirection; insensitive?: boolean | string; + key: string; }; export type { diff --git a/packages/slonik/src/types/index.ts b/packages/slonik/src/types/index.ts index 5ac58913c..687f6be9a 100644 --- a/packages/slonik/src/types/index.ts +++ b/packages/slonik/src/types/index.ts @@ -1,11 +1,11 @@ export type { SlonikConfig, SlonikOptions } from "./config"; export type { + BaseFilterInput, Database, FilterInput, SortDirection, SortInput, - BaseFilterInput, } from "./database"; export type { PaginatedList, Service } from "./service"; diff --git a/packages/slonik/src/types/service.ts b/packages/slonik/src/types/service.ts index e0306f054..1628a187a 100644 --- a/packages/slonik/src/types/service.ts +++ b/packages/slonik/src/types/service.ts @@ -1,30 +1,31 @@ -import type { Database, FilterInput, SortInput } from "./database"; import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { Database, FilterInput, SortInput } from "./database"; + type PaginatedList = { - totalCount: number; - filteredCount: number; data: readonly T[]; + filteredCount: number; + totalCount: number; }; interface Service { + all(fields: string[]): Promise>; config: ApiConfig; - database: Database; - schema: "public" | string; + count(filters?: FilterInput): Promise; - all(fields: string[]): Promise>; create(data: C): Promise; - delete(id: number | string, force?: boolean): Promise; + database: Database; + delete(id: number | string, force?: boolean): Promise; find(filters?: FilterInput, sort?: SortInput[]): Promise; - findById(id: number | string): Promise; - findOne(filters?: FilterInput, sort?: SortInput[]): Promise; + findById(id: number | string): Promise; + findOne(filters?: FilterInput, sort?: SortInput[]): Promise; list( limit?: number, offset?: number, filters?: FilterInput, sort?: SortInput[], ): Promise>; - count(filters?: FilterInput): Promise; + schema: "public" | string; update(id: number | string, data: U): Promise; } diff --git a/packages/slonik/src/types/sqlFactory.ts b/packages/slonik/src/types/sqlFactory.ts index 50b48d309..3bf2fa1b5 100644 --- a/packages/slonik/src/types/sqlFactory.ts +++ b/packages/slonik/src/types/sqlFactory.ts @@ -1,4 +1,3 @@ -import type { Database, FilterInput, SortInput } from "../types"; import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { FragmentSqlToken, @@ -6,22 +5,18 @@ import type { QuerySqlToken, } from "slonik"; +import type { Database, FilterInput, SortInput } from "../types"; + interface SqlFactory { config: ApiConfig; database: Database; - limitDefault: number; - limitMax: number; - schema: "public" | string; - table: string; - tableFragment: FragmentSqlToken; - tableIdentifier: IdentifierSqlToken; - getAllSql(fields: string[], sort?: SortInput[]): QuerySqlToken; getCountSql(filters?: FilterInput): QuerySqlToken; getCreateSql(data: Record): QuerySqlToken; getDeleteSql(id: number | string, force?: boolean): QuerySqlToken; getFindByIdSql(id: number | string): QuerySqlToken; getFindOneSql(filters?: FilterInput, sort?: SortInput[]): QuerySqlToken; + getFindSql(filters?: FilterInput, sort?: SortInput[]): QuerySqlToken; getListSql( limit?: number, @@ -34,6 +29,12 @@ interface SqlFactory { id: number | string, data: Record, ): QuerySqlToken; + limitDefault: number; + limitMax: number; + schema: "public" | string; + table: string; + tableFragment: FragmentSqlToken; + tableIdentifier: IdentifierSqlToken; } export type { SqlFactory }; diff --git a/packages/slonik/tsconfig.json b/packages/slonik/tsconfig.json index 8a8ad62d0..1628077b9 100644 --- a/packages/slonik/tsconfig.json +++ b/packages/slonik/tsconfig.json @@ -1,13 +1,9 @@ { "extends": "@prefabs.tech/tsconfig/fastify.json", - "exclude": [ - "src/**/__test__/**/*", - ], + "exclude": ["src/**/__test__/**/*"], "compilerOptions": { "baseUrl": "./", - "outDir": "./dist", + "outDir": "./dist" }, - "include": [ - "src/**/*.ts" - ] + "include": ["src/**/*.ts"] } diff --git a/packages/slonik/vite.config.ts b/packages/slonik/vite.config.ts index 201183dcb..2e5001c4e 100644 --- a/packages/slonik/vite.config.ts +++ b/packages/slonik/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { dependencies, peerDependencies } from "./package.json"; diff --git a/packages/swagger/FEATURES.md b/packages/swagger/FEATURES.md new file mode 100644 index 000000000..7df81279f --- /dev/null +++ b/packages/swagger/FEATURES.md @@ -0,0 +1,31 @@ + + +## Plugin Registration + +1. Registers `@fastify/swagger` and `@fastify/swagger-ui` together as a single plugin using one unified options object. +2. Wrapped with `fastify-plugin` so decorators and routes escape encapsulation and are available to the parent scope. + +## Configuration + +3. Accepts a single `SwaggerOptions` object with three fields: `fastifySwaggerOptions` (required), `uiOptions` (optional), and `enabled` (optional). +4. `enabled` flag (`boolean`, optional) — when explicitly `false`, skips all plugin registration; no child plugins are registered and no decorators are added. + + ```typescript + await fastify.register(swaggerPlugin, { + enabled: false, + fastifySwaggerOptions: { openapi: {} }, + }); + // fastify.swagger, fastify.swaggerUIRoutePrefix, fastify.apiDocumentationPath → all undefined + ``` + +5. `uiOptions` defaults to `{}` when omitted — `@fastify/swagger-ui` is always registered (with its own defaults) unless `enabled` is `false`. + +## Fastify Instance Decorators + +6. Decorates `fastify.swaggerUIRoutePrefix` with the value of `uiOptions.routePrefix`, falling back to `"/documentation"` when `uiOptions` is omitted or `routePrefix` is not set. +7. Decorates `fastify.apiDocumentationPath` with the same value as `swaggerUIRoutePrefix` (both always resolve to the same string). +8. Both decorators are typed on `FastifyInstance` via a module augmentation as `string | undefined` — they are `undefined` when `enabled` is `false`. + +## Type Exports + +9. Exports the `SwaggerOptions` type for consumers to type their own configuration objects. diff --git a/packages/swagger/GUIDE.md b/packages/swagger/GUIDE.md new file mode 100644 index 000000000..9e13a6036 --- /dev/null +++ b/packages/swagger/GUIDE.md @@ -0,0 +1,390 @@ +# @prefabs.tech/fastify-swagger — Developer Guide + +## Installation + +### For package consumers (npm + pnpm) + +```bash +# npm +npm install @prefabs.tech/fastify-swagger fastify fastify-plugin + +# pnpm +pnpm add @prefabs.tech/fastify-swagger fastify fastify-plugin +``` + +### For monorepo development (pnpm install / test / build) + +```bash +# from the repo root +pnpm install + +# run tests for this package only +pnpm --filter @prefabs.tech/fastify-swagger test + +# build +pnpm --filter @prefabs.tech/fastify-swagger build +``` + +## Setup + +The plugin registers both `@fastify/swagger` (spec generation) and `@fastify/swagger-ui` (UI serving) in one call. `fastifySwaggerOptions` is the only required field. + +```typescript +import Fastify from "fastify"; +import swaggerPlugin, { + type SwaggerOptions, +} from "@prefabs.tech/fastify-swagger"; + +const fastify = Fastify({ logger: true }); + +const swaggerConfig: SwaggerOptions = { + fastifySwaggerOptions: { + openapi: { + info: { + title: "My API", + version: "1.0.0", + description: "Public API documentation", + }, + }, + }, + uiOptions: { + routePrefix: "/docs", + }, +}; + +await fastify.register(swaggerPlugin, swaggerConfig); +await fastify.ready(); + +// Decorators are now available: +console.log(fastify.swaggerUIRoutePrefix); // "/docs" +console.log(fastify.apiDocumentationPath); // "/docs" + +await fastify.listen({ port: 3000 }); +``` + +All later examples assume this setup is already in place unless stated otherwise. + +--- + +## Base Libraries + +### `@fastify/swagger` — Full Passthrough + +Their docs: https://www.npmjs.com/package/@fastify/swagger + +This plugin passes the `fastifySwaggerOptions` value directly to `fastify.register(fastifySwagger, fastifySwaggerOptions)` without modification. Every option documented by `@fastify/swagger` (OpenAPI / Swagger 2 spec config, `mode`, `transform`, `refResolver`, etc.) is fully supported. We add nothing on top of `@fastify/swagger`'s behaviour. + +### `@fastify/swagger-ui` — Full Passthrough + +Their docs: https://www.npmjs.com/package/@fastify/swagger-ui + +`uiOptions` is passed directly to `fastify.register(swaggerUi, uiOptions ?? {})` without modification. Every option documented by `@fastify/swagger-ui` (`routePrefix`, `uiConfig`, `logo`, `theme`, `staticCSP`, etc.) is fully supported. The only behaviour we add is reading `uiOptions.routePrefix` to populate our own decorators (see below). + +--- + +## Features + +### 1. Single-plugin registration for swagger + swagger-ui (Feature 1) + +One `fastify.register` call installs both `@fastify/swagger` and `@fastify/swagger-ui`, keeping your bootstrap code concise. + +```typescript +// Before (without this wrapper) +await fastify.register(fastifySwagger, { + openapi: { info: { title: "API", version: "1" } }, +}); +await fastify.register(swaggerUi, { routePrefix: "/docs" }); + +// After (with this wrapper) +await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: { info: { title: "API", version: "1" } } }, + uiOptions: { routePrefix: "/docs" }, +}); +``` + +### 2. Plugin scope escaping via `fastify-plugin` (Feature 2) + +The plugin is wrapped with `fastify-plugin`, which disables Fastify's encapsulation boundary. Decorators and routes added by `@fastify/swagger` and `@fastify/swagger-ui` — as well as the decorators this plugin adds — are visible to the parent scope without needing to hoist the registration. + +```typescript +await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, +}); +await fastify.ready(); + +// Available on the root instance, not just inside a child scope: +fastify.swagger(); // works +fastify.swaggerUIRoutePrefix; // works +``` + +### 3. Unified `SwaggerOptions` configuration type (Feature 3) + +A single typed options object groups all configuration in one place. `fastifySwaggerOptions` is required; `uiOptions` and `enabled` are optional. + +```typescript +import type { SwaggerOptions } from "@prefabs.tech/fastify-swagger"; + +const config: SwaggerOptions = { + // Required — passed straight to @fastify/swagger + fastifySwaggerOptions: { + openapi: { + info: { title: "My API", version: "2.0.0" }, + components: { + securitySchemes: { + bearerAuth: { type: "http", scheme: "bearer" }, + }, + }, + }, + }, + // Optional — passed straight to @fastify/swagger-ui + uiOptions: { + routePrefix: "/docs", + uiConfig: { docExpansion: "list" }, + }, + // Optional — set to false to disable entirely + enabled: true, +}; +``` + +### 4. `enabled` flag to disable all swagger registration (Feature 4) + +Setting `enabled: false` causes the plugin to exit immediately after logging an info message. Neither `@fastify/swagger` nor `@fastify/swagger-ui` is registered, and no decorators are added to the instance. This is useful for disabling API docs in production builds without changing your registration code. + +```typescript +const isProduction = process.env.NODE_ENV === "production"; + +await fastify.register(swaggerPlugin, { + enabled: !isProduction, + fastifySwaggerOptions: { + openapi: { info: { title: "API", version: "1.0.0" } }, + }, + uiOptions: { routePrefix: "/docs" }, +}); + +await fastify.ready(); + +if (isProduction) { + // None of these are defined: + console.log(fastify.swaggerUIRoutePrefix); // undefined + console.log(fastify.apiDocumentationPath); // undefined + console.log(fastify.swagger); // undefined +} +``` + +When `enabled` is omitted or is any value other than `false`, registration proceeds normally. + +### 5. `uiOptions` defaults to `{}` when omitted (Feature 5) + +If `uiOptions` is not supplied, `@fastify/swagger-ui` is still registered with an empty options object, picking up its own defaults (UI served at `/documentation`). + +```typescript +// uiOptions omitted — UI still served at /documentation +await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, +}); +await fastify.ready(); + +// Confirmed by the decorator value: +console.log(fastify.swaggerUIRoutePrefix); // "/documentation" +``` + +### 6. `fastify.swaggerUIRoutePrefix` decorator (Feature 6) + +After the plugin is ready, `fastify.swaggerUIRoutePrefix` holds the route prefix under which the Swagger UI is served. The value comes from `uiOptions.routePrefix`; if that is absent, it defaults to `"/documentation"`. + +```typescript +await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, + uiOptions: { routePrefix: "/api-docs" }, +}); +await fastify.ready(); + +console.log(fastify.swaggerUIRoutePrefix); // "/api-docs" + +// Use it to construct links or redirect logic: +fastify.get("/", async (_req, reply) => { + reply.redirect(fastify.swaggerUIRoutePrefix!); +}); +``` + +### 7. `fastify.apiDocumentationPath` decorator (Feature 7) + +`fastify.apiDocumentationPath` is a second decorator set to the same value as `swaggerUIRoutePrefix`. It exists as a semantically distinct name that other plugins or application code can reference without being coupled to the "Swagger UI" concept specifically. + +```typescript +await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, +}); +await fastify.ready(); + +// Both resolve identically: +console.log(fastify.swaggerUIRoutePrefix); // "/documentation" +console.log(fastify.apiDocumentationPath); // "/documentation" +``` + +### 8. Module augmentation for `FastifyInstance` types (Feature 8) + +`src/index.ts` extends the `FastifyInstance` interface so TypeScript knows about both decorators without extra type assertions. The type is `string | undefined` — `undefined` when `enabled` is `false`. + +```typescript +import type { FastifyInstance } from "fastify"; + +// No type assertion needed: +function logDocsPath(fastify: FastifyInstance) { + const path: string | undefined = fastify.apiDocumentationPath; + if (path) { + console.log(`Docs available at ${path}`); + } +} +``` + +### 9. `SwaggerOptions` type export (Feature 9) + +The `SwaggerOptions` type is re-exported from the package entry point, letting consumers type their own configuration files or factory functions without importing from internal paths. + +```typescript +import type { SwaggerOptions } from "@prefabs.tech/fastify-swagger"; + +function buildSwaggerConfig(title: string, version: string): SwaggerOptions { + return { + fastifySwaggerOptions: { + openapi: { info: { title, version } }, + }, + }; +} +``` + +--- + +## Use Cases + +### Serving interactive API docs during development only + +Disable Swagger in production to avoid exposing internal API structure, while keeping it active in development and staging. + +```typescript +import Fastify from "fastify"; +import swaggerPlugin from "@prefabs.tech/fastify-swagger"; + +const fastify = Fastify({ logger: true }); + +await fastify.register(swaggerPlugin, { + enabled: process.env.NODE_ENV !== "production", + fastifySwaggerOptions: { + openapi: { + info: { title: "Internal API", version: "1.0.0" }, + components: { + securitySchemes: { + bearerAuth: { type: "http", scheme: "bearer" }, + }, + }, + }, + }, + uiOptions: { + routePrefix: "/docs", + uiConfig: { persistAuthorization: true }, + }, +}); + +await fastify.listen({ port: 3000 }); +``` + +### Redirecting the root path to API docs + +Use `apiDocumentationPath` to wire a root-level redirect without hardcoding the docs path in multiple places. + +```typescript +import Fastify from "fastify"; +import swaggerPlugin from "@prefabs.tech/fastify-swagger"; + +const fastify = Fastify({ logger: true }); + +await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { + openapi: { info: { title: "My API", version: "1.0.0" } }, + }, + uiOptions: { routePrefix: "/docs" }, +}); + +// After ready(), apiDocumentationPath is guaranteed to be set +await fastify.ready(); + +fastify.get("/", async (_req, reply) => { + reply.redirect(fastify.apiDocumentationPath!); +}); + +await fastify.listen({ port: 3000 }); +``` + +### Annotating routes and generating an OpenAPI spec + +Register routes with JSON Schema annotations and retrieve the generated OpenAPI document via `fastify.swagger()` (provided by `@fastify/swagger`). + +```typescript +import Fastify from "fastify"; +import swaggerPlugin from "@prefabs.tech/fastify-swagger"; + +const fastify = Fastify(); + +// Route registered before or after plugin — both work +fastify.get( + "/users/:id", + { + schema: { + params: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }, + response: { + 200: { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + }, + }, + }, + }, + }, + async (req) => ({ id: req.params.id, name: "Alice" }), +); + +await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { + openapi: { info: { title: "Users API", version: "1.0.0" } }, + }, +}); + +await fastify.ready(); + +const spec = fastify.swagger(); +console.log(JSON.stringify(spec, null, 2)); +// Outputs fully generated OpenAPI 3.x document +``` + +### Sharing the docs route prefix across the application + +Expose `swaggerUIRoutePrefix` via an application-level config endpoint so clients can discover where docs are served. + +```typescript +import Fastify from "fastify"; +import swaggerPlugin from "@prefabs.tech/fastify-swagger"; + +const fastify = Fastify(); + +await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { + openapi: { info: { title: "API", version: "1.0.0" } }, + }, + uiOptions: { routePrefix: "/api-docs" }, +}); + +await fastify.ready(); + +fastify.get("/meta", async () => ({ + docsUrl: fastify.swaggerUIRoutePrefix, +})); + +// GET /meta → { "docsUrl": "/api-docs" } +``` diff --git a/packages/swagger/README.md b/packages/swagger/README.md index 9824a8322..2fdb9c6a4 100644 --- a/packages/swagger/README.md +++ b/packages/swagger/README.md @@ -2,6 +2,16 @@ A [Fastify](https://github.com/fastify/fastify) plugin that provides an easy integration of swagger in fastify API. +## Why this plugin? + +In any moderately sized back-end application, maintaining OpenAPI documentation manually inevitably leads to discrepancies between actual route logic and API docs. This plugin seamlessly exposes a beautifully rendered Swagger interface out of your existing Fastify routes. We created this plugin to: + +- **Automate API Documentation**: Seamlessly parse existing Fastify JSON-schemas bound to your HTTP routes and instantly render a polished Swagger UI portal without dedicating extra engineering hours to manual documentation writing. +- **Integrate Global Configuration**: Effortlessly hook the swagger rendering preferences, base paths, and API spec metadata natively via our unified `@prefabs.tech/fastify-config` ecosystem. + +### Design Decisions: Why wrap @fastify/swagger? + +- **Config Standardization**: Wrapping `@fastify/swagger` and `@fastify/swagger-ui` behind our unified plugin mechanism forces the Swagger metadata configurations to match our strict standard monorepo constraints. Instead of repeating fastify setup boilerplate configurations in every sub-service, developers can instantiate beautifully documented APIs using a strictly typed, one-line configuration object. ## Installation @@ -14,10 +24,11 @@ npm install @prefabs.tech/fastify-swagger Install with pnpm: ```bash -pnpm add --filter "@scope/project @prefabs.tech/fastify-swagger +pnpm add --filter "@scope/project" @prefabs.tech/fastify-swagger ``` ## Configuration + To configure the swagger, add the following settings to your `config/swagger.ts` file: ```typescript @@ -32,11 +43,11 @@ const swaggerConfig: SwaggerOptions = { version: "1.0.0", }, servers: [ - { - url: 'http://localhost:3000', - description: 'Development server' - } - ], + { + description: "Development server", + url: "http://localhost:3000", + }, + ], }, }, }; @@ -50,22 +61,21 @@ Register the plugin with your Fastify instance: ```typescript import Fastify from "fastify"; -import swaggerPlugin from "@prefabs.tech/fastify-swagger" +import swaggerPlugin from "@prefabs.tech/fastify-swagger"; -import swaggerConfig from "./config/swagger" +import swaggerConfig from "./config/swagger"; const start = async () => { // Create fastify instance const fastify = Fastify(); - await fastify.register(swaggerPlugin, swaggerOptions); + await fastify.register(swaggerPlugin, swaggerConfig); await fastify.listen({ - port: 3000, host: "0.0.0.0", + port: 3000, }); }; start(); ``` - diff --git a/packages/swagger/__test__/plugin.test.ts b/packages/swagger/__test__/plugin.test.ts deleted file mode 100644 index 318aa4670..000000000 --- a/packages/swagger/__test__/plugin.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import Fastify from "fastify"; -import { describe, it, expect } from "vitest"; - -import plugin from "../src/index"; - -describe("plugin", () => { - it("should be exported as default and register without issues", async () => { - const fastify = Fastify(); - - const options = { - enabled: true, - fastifySwaggerOptions: { - openapi: { - info: { - title: "Test API", - version: "1.0.0", - }, - }, - }, - }; - - await expect(fastify.register(plugin, options)).resolves.not.toThrow(); - }); - - it("should not register API documentation when enabled is set to false", async () => { - const fastify = Fastify(); - - const options = { - enabled: false, - fastifySwaggerOptions: { - openapi: { - info: { - title: "Test API", - version: "1.0.0", - }, - }, - }, - }; - - await fastify.register(plugin, options); - await fastify.ready(); - - expect(fastify.swagger).toBeUndefined(); - expect(fastify.apiDocumentationPath).toBeUndefined(); - - await fastify.close(); - }); - - it("should change the documentation path when routePrefix is modified", async () => { - const fastify = Fastify(); - - const options = { - fastifySwaggerOptions: { - openapi: { - info: { - title: "Test API", - version: "1.0.0", - }, - }, - }, - uiOptions: { - routePrefix: "/docs", - }, - }; - - await fastify.register(plugin, options); - - expect(fastify.apiDocumentationPath).toBe("/docs"); - }); -}); diff --git a/packages/swagger/eslint.config.js b/packages/swagger/eslint.config.js index 48a1291a4..7369a1f05 100644 --- a/packages/swagger/eslint.config.js +++ b/packages/swagger/eslint.config.js @@ -1,3 +1,22 @@ import fastifyConfig from "@prefabs.tech/eslint-config/fastify.js"; +import perfectionist from "eslint-plugin-perfectionist"; -export default fastifyConfig; +export default [ + ...fastifyConfig, + { + plugins: { + perfectionist, + }, + rules: { + // Disable conflicting default/import rules + "sort-imports": "off", + "import/order": "off", + + // Enable and spread Perfectionist's recommended rules + ...perfectionist.configs["recommended-alphabetical"].rules, + + // Add any Fastify-specific rule overrides here + "@typescript-eslint/no-explicit-any": "error", + }, + }, +]; diff --git a/packages/swagger/package.json b/packages/swagger/package.json index 1c1ea3546..d37765dfd 100644 --- a/packages/swagger/package.json +++ b/packages/swagger/package.json @@ -40,6 +40,7 @@ "@types/node": "24.10.13", "@vitest/coverage-istanbul": "3.2.4", "eslint": "9.39.2", + "eslint-plugin-perfectionist": "5.8.0", "fastify": "5.7.4", "fastify-plugin": "5.1.0", "prettier": "3.8.1", diff --git a/packages/swagger/src/__test__/plugin.test.ts b/packages/swagger/src/__test__/plugin.test.ts new file mode 100644 index 000000000..db2f04fe1 --- /dev/null +++ b/packages/swagger/src/__test__/plugin.test.ts @@ -0,0 +1,155 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { afterEach, describe, expect, it } from "vitest"; + +import swaggerPlugin from "../plugin"; + +describe("swagger plugin", () => { + let fastify: FastifyInstance; + + afterEach(async () => { + await fastify.close(); + }); + + it("registers successfully with minimal required options", async () => { + fastify = Fastify(); + await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, + }); + await expect(fastify.ready()).resolves.toBeDefined(); + }); + + it("decorates instance with default documentation path when uiOptions not provided", async () => { + fastify = Fastify(); + await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, + }); + await fastify.ready(); + + expect(fastify.swaggerUIRoutePrefix).toBe("/documentation"); + expect(fastify.apiDocumentationPath).toBe("/documentation"); + }); + + it("decorates instance with custom routePrefix from uiOptions", async () => { + fastify = Fastify(); + await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, + uiOptions: { routePrefix: "/api-docs" }, + }); + await fastify.ready(); + + expect(fastify.swaggerUIRoutePrefix).toBe("/api-docs"); + expect(fastify.apiDocumentationPath).toBe("/api-docs"); + }); + + it("uses default documentation path when uiOptions is an empty object", async () => { + fastify = Fastify(); + await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, + uiOptions: {}, + }); + await fastify.ready(); + + expect(fastify.swaggerUIRoutePrefix).toBe("/documentation"); + expect(fastify.apiDocumentationPath).toBe("/documentation"); + }); + + it("serves swagger spec JSON at custom uiOptions.routePrefix", async () => { + fastify = Fastify(); + await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, + uiOptions: { routePrefix: "/api-docs" }, + }); + await fastify.ready(); + + const response = await fastify.inject({ + method: "GET", + url: "/api-docs/json", + }); + + expect(response.statusCode).toBe(200); + }); + + it("skips registration when enabled is false", async () => { + fastify = Fastify(); + await fastify.register(swaggerPlugin, { + enabled: false, + fastifySwaggerOptions: { openapi: {} }, + }); + await fastify.ready(); + + expect(fastify.swaggerUIRoutePrefix).toBeUndefined(); + expect(fastify.apiDocumentationPath).toBeUndefined(); + expect(fastify.swagger).toBeUndefined(); + + const response = await fastify.inject({ + method: "GET", + url: "/documentation/json", + }); + expect(response.statusCode).toBe(404); + }); + + it("registers when enabled is explicitly true", async () => { + fastify = Fastify(); + await fastify.register(swaggerPlugin, { + enabled: true, + fastifySwaggerOptions: { openapi: {} }, + }); + await fastify.ready(); + + expect(fastify.swaggerUIRoutePrefix).toBe("/documentation"); + }); + + it("registers when enabled is undefined (default behavior)", async () => { + fastify = Fastify(); + await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, + }); + await fastify.ready(); + + expect(fastify.swaggerUIRoutePrefix).toBe("/documentation"); + }); + + it("passes uiOptions through to swagger-ui (serves UI route)", async () => { + fastify = Fastify(); + await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, + }); + await fastify.ready(); + + const response = await fastify.inject({ + method: "GET", + url: "/documentation/json", + }); + + expect(response.statusCode).toBe(200); + }); + + it("passes fastifySwaggerOptions through (generates spec)", async () => { + fastify = Fastify(); + + fastify.get( + "/test", + { + schema: { + response: { + 200: { properties: { ok: { type: "boolean" } }, type: "object" }, + }, + }, + }, + async () => ({ ok: true }), + ); + + await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { + openapi: { + info: { title: "Test API", version: "1.0.0" }, + }, + }, + }); + await fastify.ready(); + + const spec = fastify.swagger(); + expect(spec.info.title).toBe("Test API"); + expect(spec.info.version).toBe("1.0.0"); + }); +}); diff --git a/packages/swagger/src/plugin.ts b/packages/swagger/src/plugin.ts index 2629439b6..4d70cec5b 100644 --- a/packages/swagger/src/plugin.ts +++ b/packages/swagger/src/plugin.ts @@ -1,9 +1,10 @@ +import type { FastifyInstance } from "fastify"; + import fastifySwagger from "@fastify/swagger"; import swaggerUi from "@fastify/swagger-ui"; import FastifyPlugin from "fastify-plugin"; import type { SwaggerOptions } from "./types"; -import type { FastifyInstance } from "fastify"; const plugin = async (fastify: FastifyInstance, options: SwaggerOptions) => { const { fastifySwaggerOptions, uiOptions } = options; diff --git a/packages/swagger/tsconfig.json b/packages/swagger/tsconfig.json index 8a8ad62d0..1628077b9 100644 --- a/packages/swagger/tsconfig.json +++ b/packages/swagger/tsconfig.json @@ -1,13 +1,9 @@ { "extends": "@prefabs.tech/tsconfig/fastify.json", - "exclude": [ - "src/**/__test__/**/*", - ], + "exclude": ["src/**/__test__/**/*"], "compilerOptions": { "baseUrl": "./", - "outDir": "./dist", + "outDir": "./dist" }, - "include": [ - "src/**/*.ts" - ] + "include": ["src/**/*.ts"] } diff --git a/packages/swagger/vite.config.ts b/packages/swagger/vite.config.ts index f5da6a8aa..381bcd46f 100644 --- a/packages/swagger/vite.config.ts +++ b/packages/swagger/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { dependencies, peerDependencies } from "./package.json"; @@ -25,10 +24,10 @@ export default defineConfig(({ mode }) => { output: { exports: "named", globals: { - fastify: "Fastify", - "fastify-plugin": "FastifyPlugin", "@fastify/swagger": "FastifySwagger", "@fastify/swagger-ui": "FastifySwaggerUI", + fastify: "Fastify", + "fastify-plugin": "FastifyPlugin", }, }, }, diff --git a/packages/user/FEATURES.md b/packages/user/FEATURES.md new file mode 100644 index 000000000..a13d1e872 --- /dev/null +++ b/packages/user/FEATURES.md @@ -0,0 +1,198 @@ + + +# @prefabs.tech/fastify-user — Features + +## Plugin Lifecycle + +1. **Configurable route prefix** — all route modules are registered under `config.user.routePrefix`. + +2. **Selective route module disabling** — each of the four route groups (`users`, `invitations`, `roles`, `permissions`) can be disabled independently via `routes..disabled = true`. The service layer is unaffected. + +3. **Automatic database migrations** — on registration, runs `CREATE TABLE IF NOT EXISTS` for the `users` and `invitations` tables before the server is ready. + +4. **Default role seeding** — on `onReady`, seeds `ADMIN`, `SUPERADMIN`, and `USER` into SuperTokens, plus any extra roles listed in `config.user.roles`. + +## Authentication + +5. **`fastify.verifySession()` decorator** — added to the Fastify instance; use it as a `preHandler` to require a valid SuperTokens session on any route. + +6. **`req.session` request property** — `FastifyRequest` is augmented with an optional `session` property (populated by SuperTokens after `verifySession` runs). + +7. **`req.user` request property** — `FastifyRequest` is augmented with an optional `user: User` property, populated from the database on every verified session. + +8. **Configurable refresh-token cookie path** — an `onSend` hook rewrites the `Path` attribute of the `sRefreshToken` cookie to the value of `config.user.supertokens.refreshTokenCookiePath`, so the refresh token is scoped to the refresh endpoint. + +9. **`SUPERTOKENS_CORS_HEADERS` constant** — exports the eight SuperTokens-specific request headers that must be included in `allowedHeaders` when registering `@fastify/cors`: + + ``` + anti-csrf, authorization, fdi-version, front-token, + rid, st-access-token, st-auth-mode, st-refresh-token + ``` + +10. **SuperTokens error handler auto-registration** — automatically calls `fastify.setErrorHandler(supertokensErrorHandler)` unless `config.user.supertokens.setErrorHandler === false`. + +11. **`supertokensErrorHandler` export** — exported for manual wiring when auto-registration is disabled. + +12. **Session recipe override via function factory** — each SuperTokens recipe (`session`, `thirdPartyEmailPassword`, `userRoles`, `emailVerification`) can be overridden by supplying a function `(fastify) => RecipeConfig` under `config.user.supertokens.recipes`. The function receives the Fastify instance, enabling access to config and decorators. Providing an object instead of a function merges the object into the default config. + +13. **Override merging for `apis` and `functions`** — when a recipe override includes `override.apis` or `override.functions`, each key is called as `fn(originalImplementation, fastify)` and merged on top of the default implementation, so only the keys you provide are replaced. + +14. **Email verification (opt-in)** — setting `config.user.features.signUp.emailVerification = true` adds the `EmailVerification` recipe and enforces the email-verified claim on protected routes. Default: `false`. + +15. **Third-party OAuth providers** — Apple, Facebook, GitHub, and Google providers are configurable via `config.user.supertokens.providers`; custom providers are supported via `providers.custom`. + +## User Management + +16. **`GET /me`** — returns the authenticated user's profile. If a photo exists, the `photo.url` field is a pre-signed S3 URL. Session claims (email verification, profile validation) are bypassed so users can always read their own data. + +17. **`PUT /me`** — updates mutable fields on the current user's profile. Session claims are bypassed. + +18. **`POST /change-email`** — updates the authenticated user's email address. Gated by `config.user.features.updateEmail.enabled`. Session email-verification claims are bypassed on this route. + +19. **`POST /change_password`** — validates the current password before updating. Requires a valid session. + +20. **`DELETE /me` with atomic session revocation** — soft-deletes the user record (`deleted_at`) and immediately revokes all active SuperTokens sessions in the same operation. Requires password confirmation. + +21. **`PUT /me/photo`** — accepts `multipart/form-data`, validates MIME type (`image/jpeg`, `image/png`, `image/webp`) and file size, uploads to `{userId}/photo` in the configured S3 bucket, and links the file record to the user. Session claims bypassed. + +22. **`DELETE /me/photo`** — deletes the photo from S3 and unlinks it from the user record. Session claims bypassed. + +23. **Configurable photo size limit** — `config.user.photoMaxSizeInMB` (default: `5`). + +24. **`POST /signup/admin`** — public endpoint to create the first administrator account without an invitation. + +25. **`GET /signup/admin`** — public endpoint returning `{ signUp: boolean }` indicating whether admin sign-up is currently available. + +26. **`GET /users`** — paginatable list of all users. Requires `users:list` permission. + +27. **`GET /users/:id`** — fetches a single user by ID. Requires `users:read` permission. + +28. **`PUT /users/:id/disable`** — sets the user's `disabled` flag to `true`. Requires `users:disable` permission. + +29. **`PUT /users/:id/enable`** — clears the user's `disabled` flag. Requires `users:enable` permission. + +30. **Immutable field guard (`filterUserUpdateInput`)** — applied automatically before every profile update; silently drops any attempt to set `id`, `email`, `roles`, `lastLoginAt`, `signedUpAt`, `disable`, or `enable`. Handles both camelCase and snake_case variants (e.g. `last_login_at` is also stripped). + +31. **Configurable table names** — `config.user.tables.users.name` and `config.user.tables.invitations.name` override the default table names. + +32. **Custom request handlers** — every route handler can be replaced via `config.user.handlers.user.` or `config.user.handlers.invitation.`. + +## Authorization + +33. **`fastify.hasPermission(permission)` decorator** — added to the Fastify instance; returns a `preHandler` that checks the authenticated user holds the given permission. Returns 401 without a session, 403 without the permission. + +34. **`hasUserPermission(fastify, userId, permission)` utility** — programmatic permission check; returns a boolean. + +35. **SUPERADMIN bypass** — users with the `SUPERADMIN` role pass all `hasPermission` and `hasUserPermission` checks automatically, without being explicitly granted every permission. + +36. **Built-in permission constants** — pre-defined strings to avoid typos: + + ``` + PERMISSIONS_INVITATIONS_CREATE → "invitations:create" + PERMISSIONS_INVITATIONS_DELETE → "invitations:delete" + PERMISSIONS_INVITATIONS_LIST → "invitations:list" + PERMISSIONS_INVITATIONS_RESEND → "invitations:resend" + PERMISSIONS_INVITATIONS_REVOKE → "invitations:revoke" + PERMISSIONS_USERS_DISABLE → "users:disable" + PERMISSIONS_USERS_ENABLE → "users:enable" + PERMISSIONS_USERS_LIST → "users:list" + PERMISSIONS_USERS_READ → "users:read" + ``` + +37. **Application-defined custom permissions** — `config.user.permissions` registers additional permission strings returned by `GET /permissions`, making them discoverable by role-management UIs. + +## Roles + +38. **Built-in role constants** — `ROLE_ADMIN`, `ROLE_SUPERADMIN`, `ROLE_USER` are exported. + +39. **`POST /roles`** — creates a new role with optional initial permissions. Requires a valid session. + +40. **`DELETE /roles`** — deletes a role; returns `ROLE_IN_USE` error if any user holds it. Requires a valid session. + +41. **`GET /roles`** — returns all roles with their permissions. Requires a valid session. + +42. **`GET /roles/permissions`** — returns the permissions for a named role. Requires a valid session. + +43. **`PUT /roles/permissions`** — replaces the permission set of a named role. Requires a valid session. + +44. **`isRoleExists(name)` / `areRolesExist(names)` utilities** — programmatic existence checks against SuperTokens. + +## Invitations + +45. **`POST /invitations`** — creates an invitation record, validates the target email and role, checks for a duplicate pending invitation, and sends the invitation email. Requires `invitations:create` permission. + +46. **Configurable invitation expiry** — `config.user.invitation.expireAfterInDays` sets how long an invitation is valid (default: `30`). + +47. **Configurable accept link path** — `config.user.invitation.acceptLinkPath` sets the front-end path embedded in the invitation email (default: `"/signup/token/:token"`). The `:token` placeholder is replaced with the actual token. + +48. **`GET /invitations/token/:token`** — public endpoint returning the invitation record for UI display before acceptance. + +49. **`POST /invitations/token/:token`** — public endpoint that validates the invitation, creates a SuperTokens account, opens a session, and optionally calls `config.user.invitation.postAccept(request, invitation, user)`. + +50. **`GET /invitations`** — paginatable list of all invitations. Requires `invitations:list` permission. + +51. **`PUT /invitations/revoke/:id`** — marks an invitation as revoked. Requires `invitations:revoke` permission. + +52. **`POST /invitations/resend/:id`** — re-sends the invitation email. Requires `invitations:resend` permission. + +53. **`DELETE /invitations/:id`** — permanently removes an invitation record. Requires `invitations:delete` permission. + +54. **`isInvitationValid(invitation)` utility** — returns `true` only when the invitation is pending, non-expired, non-revoked, and non-accepted. + +55. **`computeInvitationExpiresAt(config, explicitDate?)` utility** — computes the expiry timestamp using the configured `expireAfterInDays`, or returns `explicitDate` when provided. + +56. **`getOrigin(url)` utility** — extracts `scheme://host[:non-default-port]` from a URL string. Returns an empty string for bare hostnames, IP addresses without a scheme, relative paths, or any input that is not a full URL. Default ports (`80` / `443`) are stripped. + +57. **`sendInvitation(fastify, invitation, origin)` utility** — sends the invitation email; usable from custom code that bypasses the REST route. + +## Email + +58. **`validateEmail(email, config)` utility** — validates an email string against `config.user.email` options using `validator.js`. Returns `{ success: true }` or `{ success: false, message }`. Gracefully falls back to permissive defaults when no email config is provided. + +59. **Email domain whitelist / blacklist** — `config.user.email.host_whitelist` and `config.user.email.host_blacklist` restrict which domains are accepted during sign-up and invitation. + +60. **Custom email subjects and templates** — `config.user.emailOverrides` overrides the subject and `templateName` for any of the five system emails: `invitation`, `resetPassword`, `resetPasswordNotification`, `emailVerification`, `duplicateEmail`. + +61. **`sendEmail(options)` utility** — sends a templated email via `fastify.mailer`; accepts `{ fastify, subject, templateName, to, templateData }`. + +62. **`verifyEmail(userId, email)` utility** — programmatically marks a user's email as verified in SuperTokens (useful for invited users who skip the verification link). + +## Password + +63. **`validatePassword(password, config)` utility** — validates password strength against `config.user.password` options. Returns `{ success: true }` or `{ success: false, message }` listing all failed requirements. + +64. **Configurable strength thresholds** — `config.user.password` accepts `minLength` (default: `8`), `minLowercase`, `minUppercase`, `minNumbers`, `minSymbols` (all default to `0` unless configured), and scoring tuning fields (`pointsPerUnique`, `pointsPerRepeat`, `pointsForContaining*`). + +## Profile Validation Claim + +65. **`ProfileValidationClaim` custom session claim** — a SuperTokens `SessionClaim` that checks whether required profile fields are populated. Re-fetched on every request. Enable via `config.user.features.profileValidation.enabled = true` and list required fields in `features.profileValidation.fields`. + +66. **Grace period** — `config.user.features.profileValidation.gracePeriodInDays` allows users to access protected resources for N days after sign-up before the claim is enforced. After the grace period, requests fail with 403. + +67. **Per-route claim opt-out** — routes that must stay accessible regardless of profile completeness can bypass the claim via `verifySession({ overrideGlobalClaimValidators: () => [] })` (REST) or `@auth(profileValidation: false)` (GraphQL). + +## GraphQL Integration + +> Requires `config.graphql.enabled = true` and `@prefabs.tech/fastify-graphql`. + +68. **MercuriusContext extended with `user` and `roles`** — `context.user: User | undefined` and `context.roles: string[] | undefined` are populated before each resolver via `plugin.updateContext`. + +69. **`@auth` directive** — protects a field or mutation; checks (1) authenticated session, (2) non-disabled account, (3) email verified (if enabled, unless `emailVerification: false` is passed), (4) profile complete (if enabled, unless `profileValidation: false` is passed). + +70. **`@hasPermission(permission)` directive** — enforces a named permission on a GraphQL field; SUPERADMIN bypasses automatically. + +71. **User GraphQL types** — `User`, `Photo`, `Users` (paginated wrapper with `totalCount`, `filteredCount`, `data`). + +72. **User queries** — `canAdminSignUp`, `me`, `user(id)`, `users(limit, offset, filters, sort)`. + +73. **User mutations** — `adminSignUp`, `changeEmail`, `changePassword`, `deleteMe`, `disableUser`, `enableUser`, `removePhoto`, `updateMe`, `uploadPhoto`. + +74. **Invitation GraphQL types and operations** — `Invitation` type; queries `getInvitationByToken`, `listInvitation`; mutations `acceptInvitation`, `createInvitation`, `deleteInvitation`, `resendInvitation`, `revokeInvitation`. + +75. **Role GraphQL types and operations** — `Role` type; queries `roles`, `rolePermissions`; mutations `createRole`, `deleteRole`, `updateRolePermissions`. + +76. **`permissions` GraphQL query** — returns the configured permission strings. + +77. **`userSchema` merged schema export** — the complete SDL string combining all user, invitation, role, and permission type definitions; ready to pass to `mergeTypeDefs`. + +78. **Resolver exports** — `userResolver`, `invitationResolver`, `roleResolver`, `permissionResolver` are exported individually for spreading into a larger resolver map. diff --git a/packages/user/GUIDE.md b/packages/user/GUIDE.md new file mode 100644 index 000000000..914ccf1e4 --- /dev/null +++ b/packages/user/GUIDE.md @@ -0,0 +1,827 @@ +# @prefabs.tech/fastify-user — Developer Guide + +## Installation + +### For package consumers + +```bash +npm install @prefabs.tech/fastify-user +``` + +```bash +pnpm add @prefabs.tech/fastify-user +``` + +### For monorepo development + +```bash +pnpm install +pnpm --filter @prefabs.tech/fastify-user test +pnpm --filter @prefabs.tech/fastify-user build +``` + +## Setup + +This plugin requires several peer plugins registered beforehand. All subsequent examples assume this setup. + +```typescript +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import formbody from "@fastify/formbody"; +import configPlugin from "@prefabs.tech/fastify-config"; +import errorHandlerPlugin from "@prefabs.tech/fastify-error-handler"; +import userPlugin, { + SUPERTOKENS_CORS_HEADERS, +} from "@prefabs.tech/fastify-user"; + +const fastify = Fastify(); + +await fastify.register(configPlugin, { + config: { + // ...your ApiConfig... + user: { + routePrefix: "/api", + supertokens: { + connectionUri: "http://localhost:3567", + apiBasePath: "/auth", + }, + roles: ["EDITOR"], + permissions: ["posts:create", "posts:delete"], + }, + }, +}); + +await fastify.register(cors, { + allowedHeaders: SUPERTOKENS_CORS_HEADERS, + credentials: true, + origin: true, +}); +await fastify.register(formbody); +await fastify.register(errorHandlerPlugin, { + preErrorHandler: supertokensErrorHandler, // wire in the ST error handler +}); + +await fastify.register(userPlugin); +``` + +--- + +## Base Libraries + +### supertokens-node — Modified + +Provides authentication sessions, email/password sign-up/in, third-party OAuth, email verification, and role-based access. + +→ **Their docs:** [supertokens-node](https://www.npmjs.com/package/supertokens-node) + +We wrap `supertokens-node` initialization and expose a subset of its surface via `UserConfig.supertokens`. Recipe configuration can be partially or fully overridden with our merge pattern (see [Recipe overrides](#recipe-overrides)). + +**What we add on top:** database integration, user model, invitation flow, profile validation claim, `verifySession` decorator, permission middleware, GraphQL directives. + +### mercurius-auth — Modified + +Provides `@auth`-style directive authentication for Mercurius/GraphQL. + +→ **Their docs:** [mercurius-auth](https://www.npmjs.com/package/mercurius-auth) + +We register two separate `mercurius-auth` instances: one for `@auth` and one for `@hasPermission`. Both are registered automatically when `config.graphql.enabled = true`. + +**What we add on top:** session verification, email verification enforcement, profile validation enforcement, permission checks, SUPERADMIN bypass — all wired to our user model. + +--- + +## Features + +### Plugin registration + +On startup the plugin: + +1. Initializes SuperTokens and registers the Fastify SuperTokens adapter. +2. Runs `CREATE TABLE IF NOT EXISTS` for the `users` and `invitations` tables (before the server is ready). +3. Seeds built-in roles (`ADMIN`, `SUPERADMIN`, `USER`) plus any extra roles in `config.user.roles` into SuperTokens on `onReady`. +4. Registers four route groups under `config.user.routePrefix`, each independently disable-able. + +### Route prefix and selective route disabling + +All routes are registered under `config.user.routePrefix`. Any of the four route groups can be disabled: + +```typescript +user: { + routePrefix: "/api", + routes: { + invitations: { disabled: true }, + permissions: { disabled: false }, + roles: { disabled: false }, + users: { disabled: false }, + }, +} +``` + +### `fastify.verifySession()` decorator + +Protects any route with a SuperTokens session check. Use it as a `preHandler`: + +```typescript +fastify.get( + "/protected", + { + preHandler: fastify.verifySession(), + }, + async (request) => { + return { userId: request.session!.getUserId() }; + }, +); +``` + +`request.session` (type: `Session`) is populated after this hook runs. + +### `request.user` — authenticated user profile + +After `verifySession` runs, `request.user` is populated with the full `User` object from the database: + +```typescript +fastify.get( + "/me", + { + preHandler: fastify.verifySession(), + }, + async (request) => { + return request.user; // User | undefined + }, +); +``` + +### `fastify.hasPermission(permission)` decorator + +Returns a `preHandler` function. Combine with `verifySession` to require both a session and a specific permission: + +```typescript +fastify.delete( + "/posts/:id", + { + preHandler: [ + fastify.verifySession(), + fastify.hasPermission("posts:delete"), + ], + }, + handler, +); +``` + +- Returns `401` if no session. +- Returns `403` if the user lacks the permission. +- `SUPERADMIN` users bypass all permission checks. +- If the permission string is not in `config.user.permissions`, the check passes automatically. + +### `hasUserPermission(fastify, userId, permission)` utility + +Programmatic boolean permission check, for use outside route preHandlers: + +```typescript +import { hasUserPermission } from "@prefabs.tech/fastify-user"; + +const allowed = await hasUserPermission(fastify, userId, "posts:create"); +``` + +### SuperTokens error handler + +By default the plugin calls `fastify.setErrorHandler(supertokensErrorHandler)` automatically. To disable auto-registration and wire it manually (e.g. into `@prefabs.tech/fastify-error-handler`'s `preErrorHandler`): + +```typescript +import { supertokensErrorHandler } from "@prefabs.tech/fastify-user"; + +user: { + supertokens: { + setErrorHandler: false, // disable auto-registration + }, +} + +// Then wire manually: +await fastify.register(errorHandlerPlugin, { + preErrorHandler: supertokensErrorHandler, +}); +``` + +### Refresh-token cookie path + +An `onSend` hook rewrites the `Path` attribute of the `sRefreshToken` cookie, scoping the refresh token to a specific path: + +```typescript +user: { + supertokens: { + refreshTokenCookiePath: "/auth/session/refresh", + }, +} +``` + +Without this option the cookie uses SuperTokens' default path. + +### `SUPERTOKENS_CORS_HEADERS` constant + +An array of eight header names that must be included in `allowedHeaders` when registering `@fastify/cors` alongside SuperTokens: + +```typescript +import { SUPERTOKENS_CORS_HEADERS } from "@prefabs.tech/fastify-user"; + +await fastify.register(cors, { + allowedHeaders: SUPERTOKENS_CORS_HEADERS, + credentials: true, + origin: true, +}); +``` + +### Recipe overrides + +Each SuperTokens recipe can be partially or fully overridden by providing a function `(fastify) => RecipeConfig` under `config.user.supertokens.recipes`. When an object is provided instead, it is merged into the defaults. + +```typescript +user: { + supertokens: { + recipes: { + session: (fastify) => ({ + cookieDomain: fastify.config.appOrigin[0], + cookieSecure: true, + }), + }, + }, +} +``` + +For `override.apis` and `override.functions`, provide a function `(originalImpl, fastify) => partialOverride`; only the keys you return are replaced. + +### Third-party OAuth providers + +Configure Apple, Facebook, GitHub, and Google via `config.user.supertokens.providers`: + +```typescript +user: { + supertokens: { + providers: { + google: { clientId: "...", clientSecret: "..." }, + github: { clientId: "...", clientSecret: "..." }, + apple: [{ clientId: "...", keyId: "...", privateKey: "...", teamId: "..." }], + custom: [myCustomProvider], + }, + }, +} +``` + +### Email verification (opt-in) + +Enable to enforce the email-verified claim on protected routes: + +```typescript +user: { + features: { + signUp: { emailVerification: true }, + }, +} +``` + +`POST /change-email`, `GET /me`, `PUT /me`, `DELETE /me`, and `PUT/DELETE /me/photo` bypass the email-verification claim so users can still access their account while verifying. + +### Profile Validation Claim + +A custom SuperTokens session claim that checks required profile fields are populated. Enable and configure required fields: + +```typescript +user: { + features: { + profileValidation: { + enabled: true, + fields: ["photo"], // keyof UserUpdateInput + gracePeriodInDays: 7, // optional grace window after sign-up + }, + }, +} +``` + +After the grace period expires, requests to protected routes return `403` with `invalid claim` until the fields are filled. To skip the check on a specific route: + +```typescript +fastify.get( + "/onboarding", + { + preHandler: fastify.verifySession({ + overrideGlobalClaimValidators: (validators) => + validators.filter((v) => v.id !== ProfileValidationClaim.key), + }), + }, + handler, +); +``` + +### `ProfileValidationClaim` export + +Exported for use in custom route preHandlers when you need to reference the claim key directly: + +```typescript +import { ProfileValidationClaim } from "@prefabs.tech/fastify-user"; + +console.log(ProfileValidationClaim.key); // "profileValidation" +``` + +--- + +## User Routes + +All user routes are registered under `routePrefix`. The session-protected routes require `verifySession()` in their `preHandler`. + +| Method | Path | Auth | Description | +| -------- | -------------------- | --------------- | ------------------------------------- | +| `GET` | `/users` | `users:list` | Paginated user list | +| `GET` | `/users/:id` | `users:read` | Single user by ID | +| `PUT` | `/users/:id/disable` | `users:disable` | Disable a user | +| `PUT` | `/users/:id/enable` | `users:enable` | Enable a user | +| `GET` | `/me` | session | Current user's profile | +| `PUT` | `/me` | session | Update current user's profile | +| `DELETE` | `/me` | session | Soft-delete account + revoke sessions | +| `POST` | `/change-email` | session | Change email address | +| `POST` | `/change_password` | session | Change password | +| `PUT` | `/me/photo` | session | Upload profile photo (multipart) | +| `DELETE` | `/me/photo` | session | Remove profile photo | +| `POST` | `/signup/admin` | public | First-admin sign-up | +| `GET` | `/signup/admin` | public | Check admin sign-up availability | + +### Immutable field guard + +Before every `PUT /me` update, `filterUserUpdateInput` silently drops any attempt to modify `id`, `email`, `roles`, `lastLoginAt`, `signedUpAt`, `disabled`, `deletedAt`, and their `snake_case` equivalents. + +### Profile photo constraints + +- Accepted MIME types: `image/jpeg`, `image/png`, `image/webp` +- Default max size: 5 MB (override with `config.user.photoMaxSizeInMB`) +- Stored at `{userId}/photo` in `config.user.s3.bucket` + +### Custom handlers + +Any route handler can be replaced: + +```typescript +user: { + handlers: { + user: { + me: async (request, reply) => { /* custom me handler */ }, + users: async (request, reply) => { /* custom users list */ }, + }, + invitation: { + createInvitation: async (request, reply) => { /* custom create */ }, + }, + }, +} +``` + +--- + +## Invitation Routes + +| Method | Path | Auth | Description | +| -------- | --------------------------- | -------------------- | -------------------------- | +| `POST` | `/invitations` | `invitations:create` | Create and send invitation | +| `GET` | `/invitations` | `invitations:list` | Paginated invitation list | +| `GET` | `/invitations/token/:token` | public | Get invitation by token | +| `POST` | `/invitations/token/:token` | public | Accept invitation | +| `PUT` | `/invitations/revoke/:id` | `invitations:revoke` | Revoke invitation | +| `POST` | `/invitations/resend/:id` | `invitations:resend` | Resend invitation email | +| `DELETE` | `/invitations/:id` | `invitations:delete` | Delete invitation record | + +### Invitation configuration + +```typescript +user: { + invitation: { + expireAfterInDays: 14, // default: 30 + acceptLinkPath: "/join/:token", // default: "/signup/token/:token" + postAccept: async (request, invitation, user) => { + // called after a user successfully accepts an invitation + }, + }, +} +``` + +### Invitation utilities + +```typescript +import { + isInvitationValid, + computeInvitationExpiresAt, + sendInvitation, + getOrigin, +} from "@prefabs.tech/fastify-user"; + +// Check if an invitation can still be accepted +isInvitationValid(invitation); // → boolean + +// Compute expiry timestamp from config +computeInvitationExpiresAt(config); // uses expireAfterInDays +computeInvitationExpiresAt(config, "2026-06-01T00:00:00.000Z"); // explicit date + +// Send invitation email from custom code +await sendInvitation(fastify, invitation, "https://app.example.com"); + +// Extract origin from a full URL +getOrigin("https://app.example.com/path"); // → "https://app.example.com" +getOrigin("not-a-url"); // → "" +``` + +--- + +## Role Routes + +| Method | Path | Auth | Description | +| -------- | -------------------- | ------- | ------------------------------------ | +| `POST` | `/roles` | session | Create a role | +| `DELETE` | `/roles` | session | Delete a role | +| `GET` | `/roles` | session | List all roles with permissions | +| `GET` | `/roles/permissions` | session | Get permissions for a named role | +| `PUT` | `/roles/permissions` | session | Replace permissions for a named role | + +### Role utilities + +```typescript +import { + isRoleExists, + areRolesExist, + ROLE_ADMIN, + ROLE_SUPERADMIN, + ROLE_USER, +} from "@prefabs.tech/fastify-user"; + +await isRoleExists("EDITOR"); // → boolean +await areRolesExist(["EDITOR", "VIEWER"]); // → boolean (all must exist) +``` + +--- + +## Permission Routes + +| Method | Path | Auth | Description | +| ------ | -------------- | ------- | ------------------------------- | +| `GET` | `/permissions` | session | List all configured permissions | + +Register application-specific permissions so they appear in this endpoint: + +```typescript +user: { + permissions: ["posts:create", "posts:delete", "posts:publish"], +} +``` + +### Built-in permission constants + +```typescript +import { + PERMISSIONS_USERS_LIST, + PERMISSIONS_USERS_READ, + PERMISSIONS_USERS_DISABLE, + PERMISSIONS_USERS_ENABLE, + PERMISSIONS_INVITATIONS_CREATE, + PERMISSIONS_INVITATIONS_DELETE, + PERMISSIONS_INVITATIONS_LIST, + PERMISSIONS_INVITATIONS_RESEND, + PERMISSIONS_INVITATIONS_REVOKE, +} from "@prefabs.tech/fastify-user"; +``` + +--- + +## Email + +### Custom email subjects and templates + +```typescript +user: { + emailOverrides: { + invitation: { subject: "You're invited!", templateName: "my-invitation" }, + resetPassword: { subject: "Reset your password" }, + emailVerification: { templateName: "verify-email-custom" }, + }, +} +``` + +Overridable emails: `invitation`, `resetPassword`, `resetPasswordNotification`, `emailVerification`, `duplicateEmail`. + +### `sendEmail` utility + +Sends a templated email via `fastify.mailer`. `appName` from config is automatically merged into `templateData`: + +```typescript +import { sendEmail } from "@prefabs.tech/fastify-user"; + +await sendEmail({ + fastify, + subject: "Welcome!", + templateName: "welcome", + to: "user@example.com", + templateData: { firstName: "Alice" }, +}); +``` + +### `verifyEmail` utility + +Programmatically marks a user's email as verified (useful for invited users who can skip the verification link): + +```typescript +import { verifyEmail } from "@prefabs.tech/fastify-user"; + +await verifyEmail(userId, userEmail); +``` + +--- + +## Validation Utilities + +### `validateEmail(email, config)` + +Validates an email string against `config.user.email` options. Returns `{ success: true }` or `{ success: false, message }`: + +```typescript +import { validateEmail } from "@prefabs.tech/fastify-user"; + +const result = validateEmail("user@example.com", fastify.config); +if (!result.success) throw new Error(result.message); +``` + +### Email domain restrictions + +```typescript +user: { + email: { + host_whitelist: ["example.com"], // only allow these domains + host_blacklist: ["tempmail.com"], // block these domains + }, +} +``` + +### `validatePassword(password, config)` + +Validates password strength. Returns `{ success: true }` or `{ success: false, message }` with a human-readable description of failed requirements: + +```typescript +import { validatePassword } from "@prefabs.tech/fastify-user"; + +const result = validatePassword("MyP@ss1", fastify.config); +// { success: false, message: "Password should contain minimum 8 characters" } +``` + +### Password strength configuration + +```typescript +user: { + password: { + minLength: 10, + minLowercase: 1, + minUppercase: 1, + minNumbers: 1, + minSymbols: 1, + }, +} +``` + +--- + +## Database Utilities + +### Migration queries + +Exported SQL factory functions for use if you need to run migrations manually or inspect the schema: + +```typescript +import { + createUsersTableQuery, + createInvitationsTableQuery, +} from "@prefabs.tech/fastify-user"; + +await db.query(createUsersTableQuery(config)); +await db.query(createInvitationsTableQuery(config)); +``` + +### Custom table names + +```typescript +user: { + tables: { + users: { name: "app_users" }, + invitations: { name: "app_invitations" }, + }, +} +``` + +### Service and SQL factory exports + +These classes are exported for direct use in custom service layers: + +- `UserService` — database operations for users +- `InvitationService` — database operations for invitations +- `RoleService` — SuperTokens role operations +- `UserSqlFactory` — SQL fragment builder for user queries +- `InvitationSqlFactory` — SQL fragment builder for invitation queries +- `createUserFilterFragment`, `createRoleSortFragment` — reusable Slonik SQL fragments + +--- + +## GraphQL Integration + +> Requires `config.graphql.enabled = true` and `@prefabs.tech/fastify-graphql` registered before this plugin. + +### Setup + +Merge the exported schema and resolvers into your Mercurius setup: + +```typescript +import { + userSchema, + userResolver, + invitationResolver, + roleResolver, + permissionResolver, +} from "@prefabs.tech/fastify-user"; +import { mergeTypeDefs } from "@graphql-tools/merge"; + +const typeDefs = mergeTypeDefs([userSchema, yourOtherSchema]); +const resolvers = { + ...userResolver, + ...invitationResolver, + ...roleResolver, + ...permissionResolver, +}; +``` + +### `@auth` directive + +Protects a GraphQL field or mutation. Checks: (1) valid session, (2) account not disabled, (3) email verified (if enabled), (4) profile complete (if enabled). + +```graphql +type Query { + dashboard: DashboardData @auth + profile: User @auth(emailVerification: false, profileValidation: false) +} +``` + +Pass `emailVerification: false` or `profileValidation: false` to skip those checks on a specific field. + +### `@hasPermission` directive + +Enforces a named permission on a GraphQL field. `SUPERADMIN` bypasses automatically: + +```graphql +type Mutation { + deletePost(id: ID!): Boolean @hasPermission(permission: "posts:delete") +} +``` + +### `MercuriusContext` augmentation + +`context.user` and `context.roles` are available in every resolver: + +```typescript +const resolvers = { + Query: { + myData: async (_parent, _args, context) => { + const { user, roles } = context; + // ... + }, + }, +}; +``` + +### Available GraphQL operations + +**User:** `canAdminSignUp`, `me`, `user(id)`, `users(limit, offset, filters, sort)` / `adminSignUp`, `changeEmail`, `changePassword`, `deleteMe`, `disableUser`, `enableUser`, `removePhoto`, `updateMe`, `uploadPhoto` + +**Invitation:** `getInvitationByToken`, `listInvitation` / `acceptInvitation`, `createInvitation`, `deleteInvitation`, `resendInvitation`, `revokeInvitation` + +**Role:** `roles`, `rolePermissions` / `createRole`, `deleteRole`, `updateRolePermissions` + +**Permission:** `permissions` + +--- + +## ApiConfig Extension + +This package extends `ApiConfig` (from `@prefabs.tech/fastify-config`) with a `user` field. TypeScript picks this up automatically via module augmentation — no extra setup needed: + +```typescript +declare module "@prefabs.tech/fastify-config" { + interface ApiConfig { + user: UserConfig; // added by this package + } +} +``` + +--- + +## Type Exports + +| Type | Description | +| ------------------------------- | --------------------------------------------------- | +| `UserConfig` | Full plugin configuration shape | +| `SupertokensConfig` | SuperTokens sub-configuration | +| `User` | User database record | +| `AuthUser` | Combined SuperTokens + database user | +| `UserCreateInput` | Input for creating a user | +| `UserUpdateInput` | Input for updating a user | +| `Invitation` | Invitation database record | +| `InvitationCreateInput` | Input for creating an invitation | +| `InvitationUpdateInput` | Input for updating an invitation | +| `EmailOptions` | Email subject/template override shape | +| `StrongPasswordOptions` | Password strength configuration | +| `IsEmailOptions` | Email validation configuration | +| `SessionRecipe` | SuperTokens session recipe override type | +| `ThirdPartyEmailPasswordRecipe` | SuperTokens TPEP recipe override type | +| `EmailVerificationRecipe` | SuperTokens email verification recipe override type | + +--- + +## Use Cases + +### Protecting routes with session + permission + +When you need a route that requires both authentication and a specific permission: + +```typescript +fastify.delete( + "/articles/:id", + { + preHandler: [ + fastify.verifySession(), + fastify.hasPermission("articles:delete"), + ], + }, + async (request) => { + const userId = request.session!.getUserId(); + // delete the article... + }, +); +``` + +### Invitation-based onboarding flow + +When your app uses invitation-only sign-up, configure the acceptance path and add post-accept logic: + +```typescript +user: { + invitation: { + acceptLinkPath: "/onboarding/:token", + expireAfterInDays: 7, + postAccept: async (request, invitation, user) => { + // e.g. assign the user to the correct tenant + await assignUserToApp(invitation.appId, user.id); + }, + }, +} +``` + +### Enforcing profile completeness after sign-up + +When you need users to fill in required fields before accessing the app, use the profile validation claim with a grace period: + +```typescript +user: { + features: { + profileValidation: { + enabled: true, + fields: ["photo"], + gracePeriodInDays: 3, + }, + }, +} +``` + +`GET /me` and `PUT /me` bypass the claim automatically so users can always update their profile. After 3 days, any other session-protected route returns `403` until the photo is uploaded. + +### Overriding a single SuperTokens recipe function + +When you need to add custom logic to SuperTokens sign-up without replacing the entire recipe: + +```typescript +user: { + supertokens: { + recipes: { + thirdPartyEmailPassword: { + override: { + functions: (originalImpl, fastify) => ({ + emailPasswordSignUp: async (input) => { + const result = await originalImpl.emailPasswordSignUp(input); + if (result.status === "OK") { + await fastify.analytics.track("sign_up", { userId: result.user.id }); + } + return result; + }, + }), + }, + }, + }, + }, +} +``` + +### Disabling email verification on specific GraphQL fields + +When a mutation must work even before the user has verified their email: + +```graphql +type Mutation { + resendVerificationEmail: Boolean @auth(emailVerification: false) +} +``` diff --git a/packages/user/README.md b/packages/user/README.md index e3bc3e4dd..148644ee7 100644 --- a/packages/user/README.md +++ b/packages/user/README.md @@ -2,16 +2,29 @@ A [Fastify](https://github.com/fastify/fastify) plugin that provides an easy integration of user model (service, controller, resolver) in a fastify API. +## Why this plugin? + +User management—authentication, password hashing, multifactor sessions, session invalidation, and third-party SSO—is historically the most highly audited and volatile part of any backend system. We created this plugin to abstract that immense architectural complexity entirely by marrying SuperTokens directly into our monorepo toolset: + +- **Provide a Drop-In Authentication System**: Seamlessly hooks into `@prefabs.tech/fastify-slonik`, `@prefabs.tech/fastify-mailer`, and Fastify routers to rigorously manage passwords, sessions, and login states internally out of the box. +- **Instant GraphQL and REST Architectures**: Bootstraps massively scaffolded REST routes, GraphQL schemas (`userSchema`), and graph resolvers natively so you don't have to ever architect or rewrite complex authentication layers again. +- **Enforce Security By Default**: It leverages battle-tested frameworks to natively handle strong password requirements, seamless refresh token rotations, and edge-case CORS protections inherently invisible to developers. + +### Design Decisions: Why not custom JWTs, Passport.js, or Auth0? + +1. **Security Vulnerabilities vs Homemade Systems**: Maintaining a homegrown JWT authentication flow commonly leads to compromised token invalidation states, XSS exposures, or improper cryptographic recycling. Relying on an enterprise-grade framework prevents critical breaches natively. +2. **Why SuperTokens specifically**: We chose SuperTokens because it is fully open-source, architecturally flawless, and allows for extensive local overrides (e.g., custom OAuth, native password reset emails). Unlike heavy restrictive SaaS products (like Auth0 or Firebase Auth), using SuperTokens in combination with our own databases ensures you actually possess, own, and control your users' data natively without vender lock-ins. + ## Requirements -* [@fastify/cors](https://github.com/fastify/fastify-cors) -* [@fastify/formbody](https://github.com/fastify/fastify-formbody) -* [@prefabs.tech/fastify-config](../config/) -* [@prefabs.tech/fastify-mailer](../mailer/) -* [@prefabs.tech/fastify-s3](../s3/) -* [@prefabs.tech/fastify-slonik](../slonik/) -* [slonik](https://github.com/spa5k/fastify-slonik) -* [supertokens-node](https://github.com/supertokens/supertokens-node) +- [@fastify/cors](https://github.com/fastify/fastify-cors) +- [@fastify/formbody](https://github.com/fastify/fastify-formbody) +- [@prefabs.tech/fastify-config](../config/) +- [@prefabs.tech/fastify-mailer](../mailer/) +- [@prefabs.tech/fastify-s3](../s3/) +- [@prefabs.tech/fastify-slonik](../slonik/) +- [slonik](https://github.com/spa5k/fastify-slonik) +- [supertokens-node](https://github.com/supertokens/supertokens-node) ## Installation @@ -38,7 +51,9 @@ import configPlugin from "@prefabs.tech/fastify-config"; import mailerPlugin from "@prefabs.tech/fastify-mailer"; import s3Plugin, { multipartParserPlugin } from "@prefabs.tech/fastify-s3"; import slonikPlugin, { migrationPlugin } from "@prefabs.tech/fastify-slonik"; -import userPlugin, { SUPERTOKENS_CORS_HEADERS } from "@prefabs.tech/fastify-user"; +import userPlugin, { + SUPERTOKENS_CORS_HEADERS, +} from "@prefabs.tech/fastify-user"; import Fastify from "fastify"; import config from "./config"; @@ -57,10 +72,10 @@ const start = async () => { // Register cors plugin await fastify.register(corsPlugin, { - origin: config.appOrigin, allowedHeaders: ["Content-Type", ...SUPERTOKENS_CORS_HEADERS], - methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"], credentials: true, + methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"], + origin: config.appOrigin, }); // Register form-body plugin @@ -73,9 +88,9 @@ const start = async () => { await fastify.register(mailerPlugin, config.mailer); // Register multipart content-type parser plugin - await api.register(multipartParserPlugin); - - // Register mailer plugin + await fastify.register(multipartParserPlugin); + + // Register s3 plugin await fastify.register(s3Plugin); // Register fastify-user plugin @@ -83,10 +98,10 @@ const start = async () => { // Run app database migrations await fastify.register(migrationPlugin, config.slonik); - + await fastify.listen({ - port: config.port, host: "0.0.0.0", + port: config.port, }); }; @@ -94,27 +109,30 @@ start(); ``` ## Configuration + To add custom email and password validations: + ```typescript const config: ApiConfig = { // ... user: { //... email: { - host_whitelist: ["..."] + host_whitelist: ["..."], }, password: { minLength: 8, minLowercase: 1, - minUppercase: 0, minNumbers: 1, minSymbols: 0, - } - } + minUppercase: 0, + }, + }, }; ``` To overwrite ThirdPartyEmailPassword recipes from config: + ```typescript const config: ApiConfig = { // ... @@ -161,6 +179,7 @@ const config: ApiConfig = { }, }; ``` + **_NOTE:_** Each above overridden elements is a wrapper function. For example to override `emailPasswordSignUpPOST` see [emailPasswordSignUpPOST](src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUpPost.ts). ## Using GraphQL @@ -210,13 +229,13 @@ export default schema; To integrate the resolvers provided by this package, import them and merge with your application's resolvers: ```typescript -import { usersResolver } from "@prefabs.tech/fastify-user"; +import { userResolver } from "@prefabs.tech/fastify-user"; import type { IResolvers } from "mercurius"; const resolvers: IResolvers = { Mutation: { - ...usersResolver.Mutation, + ...userResolver.Mutation, }, Query: { ...userResolver.Query, diff --git a/packages/user/eslint.config.js b/packages/user/eslint.config.js index 48a1291a4..7369a1f05 100644 --- a/packages/user/eslint.config.js +++ b/packages/user/eslint.config.js @@ -1,3 +1,22 @@ import fastifyConfig from "@prefabs.tech/eslint-config/fastify.js"; +import perfectionist from "eslint-plugin-perfectionist"; -export default fastifyConfig; +export default [ + ...fastifyConfig, + { + plugins: { + perfectionist, + }, + rules: { + // Disable conflicting default/import rules + "sort-imports": "off", + "import/order": "off", + + // Enable and spread Perfectionist's recommended rules + ...perfectionist.configs["recommended-alphabetical"].rules, + + // Add any Fastify-specific rule overrides here + "@typescript-eslint/no-explicit-any": "error", + }, + }, +]; diff --git a/packages/user/package.json b/packages/user/package.json index 73efc146b..db67c3b28 100644 --- a/packages/user/package.json +++ b/packages/user/package.json @@ -19,7 +19,9 @@ "main": "./dist/prefabs-tech-fastify-user.cjs", "module": "./dist/prefabs-tech-fastify-user.js", "types": "./dist/types/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", @@ -46,6 +48,7 @@ "@types/validator": "13.15.10", "@vitest/coverage-istanbul": "3.2.4", "eslint": "9.39.2", + "eslint-plugin-perfectionist": "5.8.0", "fastify": "5.7.4", "fastify-plugin": "5.1.0", "graphql": "16.12.0", diff --git a/packages/user/src/__test__/constants.spec.ts b/packages/user/src/__test__/constants.spec.ts new file mode 100644 index 000000000..c2a13eaf1 --- /dev/null +++ b/packages/user/src/__test__/constants.spec.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_USER_PHOTO_MAX_SIZE_IN_MB, + EMAIL_VERIFICATION_MODE, + EMAIL_VERIFICATION_PATH, + ERROR_CODES, + INVITATION_ACCEPT_LINK_PATH, + INVITATION_EXPIRE_AFTER_IN_DAYS, + PERMISSIONS_INVITATIONS_CREATE, + PERMISSIONS_INVITATIONS_DELETE, + PERMISSIONS_INVITATIONS_LIST, + PERMISSIONS_INVITATIONS_RESEND, + PERMISSIONS_INVITATIONS_REVOKE, + PERMISSIONS_USERS_DISABLE, + PERMISSIONS_USERS_ENABLE, + PERMISSIONS_USERS_LIST, + PERMISSIONS_USERS_READ, + RESET_PASSWORD_PATH, + ROLE_ADMIN, + ROLE_SUPERADMIN, + ROLE_USER, + ROUTE_CHANGE_EMAIL, + ROUTE_CHANGE_PASSWORD, + ROUTE_INVITATIONS, + ROUTE_INVITATIONS_ACCEPT, + ROUTE_INVITATIONS_GET_BY_TOKEN, + ROUTE_ME, + ROUTE_ME_PHOTO, + ROUTE_PERMISSIONS, + ROUTE_ROLES, + ROUTE_ROLES_PERMISSIONS, + ROUTE_SIGNUP_ADMIN, + ROUTE_USERS, + ROUTE_USERS_DISABLE, + ROUTE_USERS_ENABLE, + ROUTE_USERS_FIND_BY_ID, + SUPERTOKENS_CORS_HEADERS, + TABLE_INVITATIONS, + TABLE_USERS, +} from "../constants"; + +describe("role constants", () => { + it("ROLE_ADMIN is 'ADMIN'", () => { + expect(ROLE_ADMIN).toBe("ADMIN"); + }); + + it("ROLE_SUPERADMIN is 'SUPERADMIN'", () => { + expect(ROLE_SUPERADMIN).toBe("SUPERADMIN"); + }); + + it("ROLE_USER is 'USER'", () => { + expect(ROLE_USER).toBe("USER"); + }); +}); + +describe("permission constants", () => { + it.each([ + [PERMISSIONS_INVITATIONS_CREATE, "invitations:create"], + [PERMISSIONS_INVITATIONS_DELETE, "invitations:delete"], + [PERMISSIONS_INVITATIONS_LIST, "invitations:list"], + [PERMISSIONS_INVITATIONS_RESEND, "invitations:resend"], + [PERMISSIONS_INVITATIONS_REVOKE, "invitations:revoke"], + [PERMISSIONS_USERS_DISABLE, "users:disable"], + [PERMISSIONS_USERS_ENABLE, "users:enable"], + [PERMISSIONS_USERS_LIST, "users:list"], + [PERMISSIONS_USERS_READ, "users:read"], + ])("permission constant %s equals expected value", (constant, expected) => { + expect(constant).toBe(expected); + }); +}); + +describe("table name constants", () => { + it("TABLE_USERS is 'users'", () => { + expect(TABLE_USERS).toBe("users"); + }); + + it("TABLE_INVITATIONS is 'invitations'", () => { + expect(TABLE_INVITATIONS).toBe("invitations"); + }); +}); + +describe("route constants", () => { + it("ROUTE_ME is '/me'", () => { + expect(ROUTE_ME).toBe("/me"); + }); + + it("ROUTE_ME_PHOTO is '/me/photo'", () => { + expect(ROUTE_ME_PHOTO).toBe("/me/photo"); + }); + + it("ROUTE_USERS is '/users'", () => { + expect(ROUTE_USERS).toBe("/users"); + }); + + it("ROUTE_USERS_FIND_BY_ID contains :id", () => { + expect(ROUTE_USERS_FIND_BY_ID).toContain(":id"); + }); + + it("ROUTE_USERS_DISABLE ends with /disable", () => { + expect(ROUTE_USERS_DISABLE).toMatch(/\/disable$/); + }); + + it("ROUTE_USERS_ENABLE ends with /enable", () => { + expect(ROUTE_USERS_ENABLE).toMatch(/\/enable$/); + }); + + it("ROUTE_CHANGE_EMAIL is '/change-email'", () => { + expect(ROUTE_CHANGE_EMAIL).toBe("/change-email"); + }); + + it("ROUTE_CHANGE_PASSWORD is '/change_password'", () => { + expect(ROUTE_CHANGE_PASSWORD).toBe("/change_password"); + }); + + it("ROUTE_SIGNUP_ADMIN is '/signup/admin'", () => { + expect(ROUTE_SIGNUP_ADMIN).toBe("/signup/admin"); + }); + + it("ROUTE_INVITATIONS is '/invitations'", () => { + expect(ROUTE_INVITATIONS).toBe("/invitations"); + }); + + it("ROUTE_INVITATIONS_ACCEPT contains :token", () => { + expect(ROUTE_INVITATIONS_ACCEPT).toContain(":token"); + }); + + it("ROUTE_INVITATIONS_GET_BY_TOKEN contains :token", () => { + expect(ROUTE_INVITATIONS_GET_BY_TOKEN).toContain(":token"); + }); + + it("ROUTE_ROLES is '/roles'", () => { + expect(ROUTE_ROLES).toBe("/roles"); + }); + + it("ROUTE_ROLES_PERMISSIONS is '/roles/permissions'", () => { + expect(ROUTE_ROLES_PERMISSIONS).toBe("/roles/permissions"); + }); + + it("ROUTE_PERMISSIONS is '/permissions'", () => { + expect(ROUTE_PERMISSIONS).toBe("/permissions"); + }); + + it("RESET_PASSWORD_PATH is '/reset-password'", () => { + expect(RESET_PASSWORD_PATH).toBe("/reset-password"); + }); + + it("EMAIL_VERIFICATION_PATH is '/verify-email'", () => { + expect(EMAIL_VERIFICATION_PATH).toBe("/verify-email"); + }); +}); + +describe("invitation constants", () => { + it("INVITATION_ACCEPT_LINK_PATH contains :token placeholder", () => { + expect(INVITATION_ACCEPT_LINK_PATH).toContain(":token"); + }); + + it("INVITATION_EXPIRE_AFTER_IN_DAYS is 30", () => { + expect(INVITATION_EXPIRE_AFTER_IN_DAYS).toBe(30); + }); +}); + +describe("SUPERTOKENS_CORS_HEADERS", () => { + it("is an array", () => { + expect(Array.isArray(SUPERTOKENS_CORS_HEADERS)).toBe(true); + }); + + it.each([ + "anti-csrf", + "authorization", + "fdi-version", + "front-token", + "rid", + "st-access-token", + "st-auth-mode", + "st-refresh-token", + ])("includes '%s'", (header) => { + expect(SUPERTOKENS_CORS_HEADERS).toContain(header); + }); + + it("has exactly 8 headers", () => { + expect(SUPERTOKENS_CORS_HEADERS).toHaveLength(8); + }); +}); + +describe("ERROR_CODES", () => { + it.each([ + ["CHANGE_PASSWORD", "CHANGE_PASSWORD_ERROR"], + ["INVALID_EMAIL", "INVALID_EMAIL_ERROR"], + ["INVALID_PASSWORD", "INVALID_PASSWORD_ERROR"], + ["INVITATION_ALREADY_EXISTS", "INVITATION_ALREADY_EXISTS_ERROR"], + ["INVITATION_NOT_FOUND", "INVITATION_NOT_FOUND_ERROR"], + ["PHOTO_FILE_MISSING", "PHOTO_FILE_MISSING_ERROR"], + ["PHOTO_FILE_TOO_LARGE", "PHOTO_FILE_TOO_LARGE_ERROR"], + ["ROLE_ALREADY_EXISTS", "ROLE_ALREADY_EXISTS_ERROR"], + ["ROLE_IN_USE", "ROLE_IN_USE_ERROR"], + ["ROLE_NOT_FOUND", "ROLE_NOT_FOUND_ERROR"], + ["ROLE_NOT_SUPPORTED", "ROLE_NOT_SUPPORTED_ERROR"], + ["UNSUPPORTED_PHOTO_FILE_TYPE", "UNSUPPORTED_PHOTO_FILE_TYPE_ERROR"], + ["USER_ALREADY_EXISTS", "USER_ALREADY_EXISTS_ERROR"], + ["USER_NOT_FOUND", "USER_NOT_FOUND_ERROR"], + ] as const)("ERROR_CODES.%s is '%s'", (key, expected) => { + expect(ERROR_CODES[key]).toBe(expected); + }); +}); + +describe("photo constants", () => { + it("DEFAULT_USER_PHOTO_MAX_SIZE_IN_MB is 5", () => { + expect(DEFAULT_USER_PHOTO_MAX_SIZE_IN_MB).toBe(5); + }); +}); + +describe("email verification constants", () => { + it("EMAIL_VERIFICATION_MODE is 'REQUIRED'", () => { + expect(EMAIL_VERIFICATION_MODE).toBe("REQUIRED"); + }); +}); diff --git a/packages/user/src/__test__/plugin.test.ts b/packages/user/src/__test__/plugin.test.ts new file mode 100644 index 000000000..380f6ba7e --- /dev/null +++ b/packages/user/src/__test__/plugin.test.ts @@ -0,0 +1,361 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ROUTE_INVITATIONS, + ROUTE_ME, + ROUTE_PERMISSIONS, + ROUTE_ROLES, + ROUTE_USERS, +} from "../constants"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockRunMigrations = vi.fn().mockResolvedValue(); +const mockSeedRoles = vi.fn().mockResolvedValue(); +const mockMercuriusAuthPlugin = vi.fn().mockResolvedValue(); + +vi.mock("../migrations/runMigrations", () => ({ default: mockRunMigrations })); +vi.mock("../lib/seedRoles", () => ({ default: mockSeedRoles })); +vi.mock("../mercurius-auth/plugin", () => ({ + default: mockMercuriusAuthPlugin, +})); + +// Mock the supertokens plugin as a noop so it doesn't try to connect to a +// real SuperTokens server. verifySession is pre-decorated in buildFastify below. +vi.mock("../supertokens", () => ({ + default: async () => {}, +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +// The route schemas reference "ErrorResponse#" which is registered by +// @prefabs.tech/fastify-error-handler in production. Register it manually here. +const errorResponseSchema = { + $id: "ErrorResponse", + additionalProperties: true, + properties: { + code: { type: "string" }, + error: { type: "string" }, + message: { type: "string" }, + statusCode: { type: "number" }, + }, + type: "object", +}; + +const buildFastify = ( + userConfig: Record = {}, + rootConfig: Record = {}, + slonik: Record = {}, +) => { + // Disable AJV strict mode so that custom keywords registered by + // peer plugins (e.g. `isFile` from @fastify/multipart) do not + // cause schema-compilation errors in the test environment. + const fastify = Fastify({ + ajv: { customOptions: { strict: false } }, + logger: false, + }); + fastify.addSchema(errorResponseSchema); + + fastify.decorate("config", { + appName: "TestApp", + appOrigin: ["http://localhost"], + baseUrl: "http://localhost", + ...rootConfig, + user: { + supertokens: { connectionUri: "http://localhost:3567" }, + ...userConfig, + }, + }); + fastify.decorate("slonik", slonik); + + // verifySession is normally added by the supertokens plugin. Since that plugin + // is mocked as a noop (to avoid real network calls), we add it here instead. + // It must return a function (a preHandler) when called with any options. + fastify.decorate( + "verifySession", + vi.fn().mockReturnValue(async () => {}), + ); + + fastify.decorate("httpErrors", { + forbidden: (message: string) => + Object.assign(new Error(message), { statusCode: 403 }), + notFound: (message: string) => + Object.assign(new Error(message), { statusCode: 404 }), + unauthorized: (message: string) => + Object.assign(new Error(message), { statusCode: 401 }), + }); + + return fastify; +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("userPlugin — decorators", async () => { + const { default: plugin } = await import("../plugin"); + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + + it("decorates the instance with hasPermission", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasPermission).toBeDefined(); + expect(typeof fastify.hasPermission).toBe("function"); + await fastify.close(); + }); + + it("calls runMigrations during registration", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(mockRunMigrations).toHaveBeenCalledOnce(); + await fastify.close(); + }); + + it("calls seedRoles on ready", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(mockSeedRoles).toHaveBeenCalledOnce(); + await fastify.close(); + }); +}); + +describe("userPlugin — invitations routes", async () => { + const { default: plugin } = await import("../plugin"); + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + + it("registers GET /invitations by default", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "GET", url: ROUTE_INVITATIONS })).toBe( + true, + ); + await fastify.close(); + }); + + it("skips invitations routes when routes.invitations.disabled === true", async () => { + fastify = buildFastify({ routes: { invitations: { disabled: true } } }); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "GET", url: ROUTE_INVITATIONS })).toBe( + false, + ); + await fastify.close(); + }); +}); + +describe("userPlugin — permissions routes", async () => { + const { default: plugin } = await import("../plugin"); + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + + it("registers GET /permissions by default", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "GET", url: ROUTE_PERMISSIONS })).toBe( + true, + ); + await fastify.close(); + }); + + it("skips permissions routes when routes.permissions.disabled === true", async () => { + fastify = buildFastify({ routes: { permissions: { disabled: true } } }); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "GET", url: ROUTE_PERMISSIONS })).toBe( + false, + ); + await fastify.close(); + }); +}); + +describe("userPlugin — roles routes", async () => { + const { default: plugin } = await import("../plugin"); + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + + it("registers GET /roles by default", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "GET", url: ROUTE_ROLES })).toBe(true); + await fastify.close(); + }); + + it("skips roles routes when routes.roles.disabled === true", async () => { + fastify = buildFastify({ routes: { roles: { disabled: true } } }); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "GET", url: ROUTE_ROLES })).toBe(false); + await fastify.close(); + }); +}); + +describe("userPlugin — users routes", async () => { + const { default: plugin } = await import("../plugin"); + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + + it("registers GET /users by default", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "GET", url: ROUTE_USERS })).toBe(true); + await fastify.close(); + }); + + it("registers GET /me by default", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "GET", url: ROUTE_ME })).toBe(true); + await fastify.close(); + }); + + it("skips users routes when routes.users.disabled === true", async () => { + fastify = buildFastify({ routes: { users: { disabled: true } } }); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "GET", url: ROUTE_USERS })).toBe(false); + await fastify.close(); + }); +}); + +describe("userPlugin — routePrefix", async () => { + const { default: plugin } = await import("../plugin"); + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + + it("mounts routes under the configured routePrefix", async () => { + fastify = buildFastify({ routePrefix: "/api/v1" }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ method: "GET", url: `/api/v1${ROUTE_USERS}` }), + ).toBe(true); + expect(fastify.hasRoute({ method: "GET", url: `/api/v1${ROUTE_ME}` })).toBe( + true, + ); + await fastify.close(); + }); + + it("mounts routes without a prefix when routePrefix is not set", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + // Routes should exist at the root path (no prefix) + expect(fastify.hasRoute({ method: "GET", url: ROUTE_USERS })).toBe(true); + await fastify.close(); + }); +}); + +describe("userPlugin — seedRoles receives user config", async () => { + const { default: plugin } = await import("../plugin"); + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + + it("passes the user config to seedRoles", async () => { + const customRoles = ["MODERATOR", "EDITOR"]; + fastify = buildFastify({ roles: customRoles }); + await fastify.register(plugin); + await fastify.ready(); + + expect(mockSeedRoles).toHaveBeenCalledWith( + expect.objectContaining({ roles: customRoles }), + ); + await fastify.close(); + }); +}); + +describe("userPlugin — runMigrations wiring", async () => { + const { default: plugin } = await import("../plugin"); + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + + it("passes fastify config and slonik to runMigrations", async () => { + const slonik = { pool: "test-pool" }; + fastify = buildFastify({}, {}, slonik); + await fastify.register(plugin); + await fastify.ready(); + + expect(mockRunMigrations).toHaveBeenCalledWith(fastify.config, slonik); + await fastify.close(); + }); +}); + +describe("userPlugin — GraphQL mercurius-auth wiring", async () => { + const { default: plugin } = await import("../plugin"); + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + + it("does not register mercurius-auth integration when graphql is omitted from config", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(mockMercuriusAuthPlugin).not.toHaveBeenCalled(); + await fastify.close(); + }); + + it("does not register mercurius-auth integration when graphql.enabled is false", async () => { + fastify = buildFastify({}, { graphql: { enabled: false } }); + await fastify.register(plugin); + await fastify.ready(); + + expect(mockMercuriusAuthPlugin).not.toHaveBeenCalled(); + await fastify.close(); + }); + + it("registers mercurius-auth integration when graphql.enabled is true", async () => { + fastify = buildFastify({}, { graphql: { enabled: true } }); + await fastify.register(plugin); + await fastify.ready(); + + // Avoid matchers that traverse the Fastify instance in mock call records + // (e.g. toHaveBeenCalledOnce, toEqual on args containing fastify) — they can + // trip Fastify's listeningOrigin getter before listen(). + expect(mockMercuriusAuthPlugin.mock.calls.length).toBe(1); + expect(mockMercuriusAuthPlugin.mock.calls[0]?.[2]).toBeTypeOf("function"); + + await fastify.close(); + }); +}); + +describe("userPlugin — default export", async () => { + const { default: plugin } = await import("../plugin"); + + it("exposes updateContext for Mercurius context wiring", () => { + expect(plugin.updateContext).toBeDefined(); + expect(typeof plugin.updateContext).toBe("function"); + }); +}); diff --git a/packages/user/src/constants.ts b/packages/user/src/constants.ts index 1b1e32691..8ad70f6d2 100644 --- a/packages/user/src/constants.ts +++ b/packages/user/src/constants.ts @@ -60,9 +60,9 @@ const ERROR_CODES = { PHOTO_FILE_MISSING: "PHOTO_FILE_MISSING_ERROR", PHOTO_FILE_TOO_LARGE: "PHOTO_FILE_TOO_LARGE_ERROR", ROLE_ALREADY_EXISTS: "ROLE_ALREADY_EXISTS_ERROR", + ROLE_IN_USE: "ROLE_IN_USE_ERROR", ROLE_NOT_FOUND: "ROLE_NOT_FOUND_ERROR", ROLE_NOT_SUPPORTED: "ROLE_NOT_SUPPORTED_ERROR", - ROLE_IN_USE: "ROLE_IN_USE_ERROR", UNKNOWN_ROLE_ERROR: "UNKNOWN_ROLE_ERROR", UNSUPPORTED_PHOTO_FILE_TYPE: "UNSUPPORTED_PHOTO_FILE_TYPE_ERROR", USER_ALREADY_EXISTS: "USER_ALREADY_EXISTS_ERROR", @@ -87,6 +87,7 @@ export { ERROR_CODES, INVITATION_ACCEPT_LINK_PATH, INVITATION_EXPIRE_AFTER_IN_DAYS, + PERMISSIONS_INVITATIONS_CREATE, PERMISSIONS_INVITATIONS_DELETE, PERMISSIONS_INVITATIONS_LIST, PERMISSIONS_INVITATIONS_RESEND, @@ -112,13 +113,12 @@ export { ROUTE_ME_PHOTO, ROUTE_PERMISSIONS, ROUTE_ROLES, - ROUTE_USERS_FIND_BY_ID, - PERMISSIONS_INVITATIONS_CREATE, ROUTE_ROLES_PERMISSIONS, ROUTE_SIGNUP_ADMIN, ROUTE_USERS, ROUTE_USERS_DISABLE, ROUTE_USERS_ENABLE, + ROUTE_USERS_FIND_BY_ID, SUPERTOKENS_CORS_HEADERS, TABLE_INVITATIONS, TABLE_USERS, diff --git a/packages/user/src/graphql/schema.ts b/packages/user/src/graphql/schema.ts index fb5f3ebf5..ff2848c76 100644 --- a/packages/user/src/graphql/schema.ts +++ b/packages/user/src/graphql/schema.ts @@ -1,4 +1,4 @@ -import { mergeTypeDefs, baseSchema } from "@prefabs.tech/fastify-graphql"; +import { baseSchema, mergeTypeDefs } from "@prefabs.tech/fastify-graphql"; import invitationSchema from "../model/invitations/graphql/schema"; import roleSchema from "../model/roles/graphql/schema"; diff --git a/packages/user/src/index.ts b/packages/user/src/index.ts index 592b5758b..4b7a96248 100644 --- a/packages/user/src/index.ts +++ b/packages/user/src/index.ts @@ -1,7 +1,7 @@ -import hasPermission from "./middlewares/hasPermission"; - import type { User, UserConfig } from "./types"; +import hasPermission from "./middlewares/hasPermission"; + declare module "fastify" { interface FastifyInstance { hasPermission: typeof hasPermission; @@ -15,7 +15,7 @@ declare module "fastify" { declare module "mercurius" { interface MercuriusContext { roles: string[] | undefined; - user: User | undefined; + user: undefined | User; } } @@ -25,48 +25,48 @@ declare module "@prefabs.tech/fastify-config" { } } -export { default } from "./plugin"; +export * from "./constants"; -export { default as userResolver } from "./model/users/graphql/resolver"; -export { default as UserSqlFactory } from "./model/users/sqlFactory"; -export { default as UserService } from "./model/users/service"; -export { default as getUserService } from "./lib/getUserService"; -export { default as userRoutes } from "./model/users/controller"; -export { default as invitationResolver } from "./model/invitations/graphql/resolver"; -export { default as InvitationSqlFactory } from "./model/invitations/sqlFactory"; -export { default as InvitationService } from "./model/invitations/service"; -export { default as getInvitationService } from "./lib/getInvitationService"; -export { default as invitationRoutes } from "./model/invitations/controller"; -export { default as permissionResolver } from "./model/permissions/resolver"; -export { default as permissionRoutes } from "./model/permissions/controller"; -export { default as RoleService } from "./model/roles/service"; -export { default as roleResolver } from "./model/roles/graphql/resolver"; -export { default as roleRoutes } from "./model/roles/controller"; -// [DU 2023-AUG-07] use formatDate from "@prefabs.tech/fastify-slonik" package -export { formatDate } from "@prefabs.tech/fastify-slonik"; +export { default as userSchema } from "./graphql/schema"; export { default as computeInvitationExpiresAt } from "./lib/computeInvitationExpiresAt"; +export { default as getInvitationService } from "./lib/getInvitationService"; export { default as getOrigin } from "./lib/getOrigin"; +export { default as getUserService } from "./lib/getUserService"; +export { default as hasUserPermission } from "./lib/hasUserPermission"; export { default as isInvitationValid } from "./lib/isInvitationValid"; export { default as sendEmail } from "./lib/sendEmail"; export { default as sendInvitation } from "./lib/sendInvitation"; export { default as verifyEmail } from "./lib/verifyEmail"; -export { default as isRoleExists } from "./supertokens/utils/isRoleExists"; +export * from "./migrations/queries"; +export { default as invitationRoutes } from "./model/invitations/controller"; +export { default as invitationResolver } from "./model/invitations/graphql/resolver"; +export { default as InvitationService } from "./model/invitations/service"; +export { default as InvitationSqlFactory } from "./model/invitations/sqlFactory"; +export { default as permissionRoutes } from "./model/permissions/controller"; +export { default as permissionResolver } from "./model/permissions/resolver"; +export { default as roleRoutes } from "./model/roles/controller"; +export { default as roleResolver } from "./model/roles/graphql/resolver"; +export { default as RoleService } from "./model/roles/service"; +export { default as userRoutes } from "./model/users/controller"; +export { default as userResolver } from "./model/users/graphql/resolver"; +export { default as UserService } from "./model/users/service"; +export { + createRoleSortFragment, + createUserFilterFragment, +} from "./model/users/sql"; +export { default as UserSqlFactory } from "./model/users/sqlFactory"; +export { default } from "./plugin"; +export { errorHandler as supertokensErrorHandler } from "./supertokens/errorHandler"; export { default as areRolesExist } from "./supertokens/utils/areRolesExist"; -export { default as validateEmail } from "./validator/email"; -export { default as validatePassword } from "./validator/password"; -export { default as hasUserPermission } from "./lib/hasUserPermission"; -export { default as ProfileValidationClaim } from "./supertokens/utils/profileValidationClaim"; export { default as createUserContext } from "./supertokens/utils/createUserContext"; -export { default as userSchema } from "./graphql/schema"; -export { errorHandler as supertokensErrorHandler } from "./supertokens/errorHandler"; +export { default as isRoleExists } from "./supertokens/utils/isRoleExists"; +export { default as ProfileValidationClaim } from "./supertokens/utils/profileValidationClaim"; -export * from "./migrations/queries"; +export type * from "./types"; -export * from "./constants"; +export { default as validateEmail } from "./validator/email"; -export type * from "./types"; +export { default as validatePassword } from "./validator/password"; -export { - createRoleSortFragment, - createUserFilterFragment, -} from "./model/users/sql"; +// [DU 2023-AUG-07] use formatDate from "@prefabs.tech/fastify-slonik" package +export { formatDate } from "@prefabs.tech/fastify-slonik"; diff --git a/packages/user/src/lib/__test__/computeInvitationExpiresAt.spec.ts b/packages/user/src/lib/__test__/computeInvitationExpiresAt.spec.ts new file mode 100644 index 000000000..1eecea499 --- /dev/null +++ b/packages/user/src/lib/__test__/computeInvitationExpiresAt.spec.ts @@ -0,0 +1,79 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { INVITATION_EXPIRE_AFTER_IN_DAYS } from "../../constants"; +import computeInvitationExpiresAt from "../computeInvitationExpiresAt"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +const FIXED_NOW = new Date("2024-06-15T12:00:00.000Z").getTime(); + +const baseConfig = { + user: {}, +} as unknown as ApiConfig; + +describe("computeInvitationExpiresAt", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(FIXED_NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns the provided expireTime as-is when supplied", () => { + const expireTime = "2099-12-31 23:59:59.000"; + + expect(computeInvitationExpiresAt(baseConfig, expireTime)).toBe(expireTime); + }); + + it("returns a formatted date string when expireTime is not provided", () => { + const result = computeInvitationExpiresAt(baseConfig); + + // Format: "YYYY-MM-DD HH:mm:ss.mmm" + expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}$/); + }); + + it(`uses the default expiry of ${INVITATION_EXPIRE_AFTER_IN_DAYS} days when not configured`, () => { + const result = computeInvitationExpiresAt(baseConfig); + + const expectedDate = new Date( + FIXED_NOW + INVITATION_EXPIRE_AFTER_IN_DAYS * MS_PER_DAY, + ); + const expectedString = expectedDate + .toISOString() + .slice(0, 23) + .replace("T", " "); + + expect(result).toBe(expectedString); + }); + + it("uses expireAfterInDays from config when provided", () => { + const config = { + user: { invitation: { expireAfterInDays: 7 } }, + } as unknown as ApiConfig; + + const result = computeInvitationExpiresAt(config); + + const expectedDate = new Date(FIXED_NOW + 7 * MS_PER_DAY); + const expectedString = expectedDate + .toISOString() + .slice(0, 23) + .replace("T", " "); + + expect(result).toBe(expectedString); + }); + + it("expiry date is later than current time", () => { + const result = computeInvitationExpiresAt(baseConfig); + + const now = new Date(FIXED_NOW) + .toISOString() + .slice(0, 23) + .replace("T", " "); + + expect(result > now).toBe(true); + }); +}); diff --git a/packages/user/src/lib/__test__/getInvitationLink.spec.ts b/packages/user/src/lib/__test__/getInvitationLink.spec.ts new file mode 100644 index 000000000..cbb336615 --- /dev/null +++ b/packages/user/src/lib/__test__/getInvitationLink.spec.ts @@ -0,0 +1,121 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +import { describe, expect, it } from "vitest"; + +import type { Invitation } from "../../types/invitation"; + +import { INVITATION_ACCEPT_LINK_PATH } from "../../constants"; +import getInvitationLink from "../getInvitationLink"; + +const baseInvitation: Invitation = { + createdAt: Date.now(), + email: "user@example.com", + expiresAt: Date.now() + 1000 * 60 * 60 * 24 * 30, + id: 1, + invitedById: "inviter-id", + role: "USER", + token: "test-uuid-token", + updatedAt: Date.now(), +}; + +const baseConfig = { + user: {}, +} as unknown as ApiConfig; + +describe("getInvitationLink", () => { + it("uses the default accept link path when not configured", () => { + const link = getInvitationLink( + baseConfig, + baseInvitation, + "https://app.example.com", + ); + + expect(link).toBe( + `https://app.example.com/signup/token/${baseInvitation.token}`, + ); + }); + + it("substitutes the invitation token into the default path", () => { + const invitation: Invitation = { + ...baseInvitation, + token: "my-special-token", + }; + + const link = getInvitationLink( + baseConfig, + invitation, + "https://app.example.com", + ); + + expect(link).toContain("my-special-token"); + expect(link).not.toContain(":token"); + }); + + it("uses custom acceptLinkPath from config", () => { + const config = { + user: { + invitation: { acceptLinkPath: "/onboarding/accept/:token" }, + }, + } as unknown as ApiConfig; + + const link = getInvitationLink( + config, + baseInvitation, + "https://app.example.com", + ); + + expect(link).toBe( + `https://app.example.com/onboarding/accept/${baseInvitation.token}`, + ); + }); + + it("preserves origin correctly in the returned URL", () => { + const link = getInvitationLink( + baseConfig, + baseInvitation, + "https://staging.myapp.io", + ); + + expect(link.startsWith("https://staging.myapp.io/")).toBe(true); + }); + + it("replaces all occurrences of :token in the path", () => { + const config = { + user: { + invitation: { acceptLinkPath: "/a/:token/verify/:token" }, + }, + } as unknown as ApiConfig; + + const link = getInvitationLink( + config, + baseInvitation, + "https://app.example.com", + ); + + expect(link).toBe( + `https://app.example.com/a/${baseInvitation.token}/verify/${baseInvitation.token}`, + ); + }); + + it("does not replace :token when followed by a word character", () => { + const config = { + user: { + invitation: { acceptLinkPath: "/invite/:tokenizer" }, + }, + } as unknown as ApiConfig; + + const link = getInvitationLink( + config, + baseInvitation, + "https://app.example.com", + ); + + // :tokenizer should NOT be replaced + expect(link).toContain(":tokenizer"); + expect(link).not.toContain(baseInvitation.token); + }); + + it("default INVITATION_ACCEPT_LINK_PATH constant contains :token placeholder", () => { + expect(INVITATION_ACCEPT_LINK_PATH).toContain(":token"); + }); +}); diff --git a/packages/user/src/lib/__test__/hasUserPermission.spec.ts b/packages/user/src/lib/__test__/hasUserPermission.spec.ts new file mode 100644 index 000000000..9c78492b4 --- /dev/null +++ b/packages/user/src/lib/__test__/hasUserPermission.spec.ts @@ -0,0 +1,142 @@ +import type { FastifyInstance } from "fastify"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ROLE_SUPERADMIN } from "../../constants"; +import hasUserPermission from "../hasUserPermission"; + +// Mock supertokens UserRoles so we can control what roles/permissions come back +// without needing a running SuperTokens server. +vi.mock("supertokens-node/recipe/userroles", () => ({ + default: { + getPermissionsForRole: vi.fn(), + getRolesForUser: vi.fn(), + }, +})); + +import UserRoles from "supertokens-node/recipe/userroles"; + +const mockGetRolesForUser = vi.mocked(UserRoles.getRolesForUser); +const mockGetPermissionsForRole = vi.mocked(UserRoles.getPermissionsForRole); + +const makeFastify = (permissions?: string[]) => + ({ config: { user: { permissions } } }) as unknown as FastifyInstance; + +describe("hasUserPermission", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("short-circuit: permission not registered in config", () => { + it("returns true when config.user.permissions is undefined", async () => { + const result = await hasUserPermission( + makeFastify(), + "user-1", + "reports:export", + ); + + expect(result).toBe(true); + expect(mockGetRolesForUser).not.toHaveBeenCalled(); + }); + + it("returns true when config.user.permissions is an empty array", async () => { + const result = await hasUserPermission( + makeFastify([]), + "user-1", + "reports:export", + ); + + expect(result).toBe(true); + expect(mockGetRolesForUser).not.toHaveBeenCalled(); + }); + + it("returns true when the requested permission is not in the configured list", async () => { + const result = await hasUserPermission( + makeFastify(["billing:manage"]), + "user-1", + "reports:export", // not in the list + ); + + expect(result).toBe(true); + expect(mockGetRolesForUser).not.toHaveBeenCalled(); + }); + }); + + describe("SUPERADMIN bypass", () => { + it("returns true for a SUPERADMIN regardless of the requested permission", async () => { + mockGetRolesForUser.mockResolvedValue({ + roles: [ROLE_SUPERADMIN], + status: "OK", + }); + + const result = await hasUserPermission( + makeFastify(["billing:manage"]), + "superadmin-1", + "billing:manage", + ); + + expect(result).toBe(true); + expect(mockGetPermissionsForRole).not.toHaveBeenCalled(); + }); + }); + + describe("permission check via role", () => { + it("returns true when the user holds a role that grants the required permission", async () => { + mockGetRolesForUser.mockResolvedValue({ + roles: ["EDITOR"], + status: "OK", + }); + mockGetPermissionsForRole.mockResolvedValue({ + permissions: ["content:publish", "billing:manage"], + status: "OK", + }); + + const result = await hasUserPermission( + makeFastify(["billing:manage"]), + "user-1", + "billing:manage", + ); + + expect(result).toBe(true); + }); + + it("returns false when none of the user's roles grant the required permission", async () => { + mockGetRolesForUser.mockResolvedValue({ + roles: ["VIEWER"], + status: "OK", + }); + mockGetPermissionsForRole.mockResolvedValue({ + permissions: ["content:read"], + status: "OK", + }); + + const result = await hasUserPermission( + makeFastify(["billing:manage"]), + "user-1", + "billing:manage", + ); + + expect(result).toBe(false); + }); + + it("de-duplicates permissions when multiple roles grant the same permission", async () => { + mockGetRolesForUser.mockResolvedValue({ + roles: ["ROLE_A", "ROLE_B"], + status: "OK", + }); + // Both roles grant the same permission + mockGetPermissionsForRole.mockResolvedValue({ + permissions: ["billing:manage"], + status: "OK", + }); + + const result = await hasUserPermission( + makeFastify(["billing:manage"]), + "user-1", + "billing:manage", + ); + + expect(result).toBe(true); + }); + }); +}); diff --git a/packages/user/src/lib/__test__/isInvitationValid.spec.ts b/packages/user/src/lib/__test__/isInvitationValid.spec.ts new file mode 100644 index 000000000..cfedb86e0 --- /dev/null +++ b/packages/user/src/lib/__test__/isInvitationValid.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import type { Invitation } from "../../types/invitation"; + +import isInvitationValid from "../isInvitationValid"; + +const baseInvitation: Invitation = { + createdAt: Date.now(), + email: "user@example.com", + expiresAt: Date.now() + 1000 * 60 * 60 * 24 * 30, // 30 days from now + id: 1, + invitedById: "inviter-id", + role: "USER", + token: "abc-token-uuid", + updatedAt: Date.now(), +}; + +describe("isInvitationValid", () => { + it("returns true for a pending, non-expired invitation", () => { + expect(isInvitationValid(baseInvitation)).toBe(true); + }); + + it("returns false when invitation has been accepted", () => { + const invitation: Invitation = { + ...baseInvitation, + acceptedAt: Date.now() - 1000, + }; + + expect(isInvitationValid(invitation)).toBe(false); + }); + + it("returns false when invitation has been revoked", () => { + const invitation: Invitation = { + ...baseInvitation, + revokedAt: Date.now() - 1000, + }; + + expect(isInvitationValid(invitation)).toBe(false); + }); + + it("returns false when invitation has expired", () => { + const invitation: Invitation = { + ...baseInvitation, + expiresAt: Date.now() - 1, + }; + + expect(isInvitationValid(invitation)).toBe(false); + }); + + it("returns false when invitation is accepted, revoked, and expired simultaneously", () => { + const past = Date.now() - 1000; + const invitation: Invitation = { + ...baseInvitation, + acceptedAt: past, + expiresAt: past, + revokedAt: past, + }; + + expect(isInvitationValid(invitation)).toBe(false); + }); + + it("returns true when expiry is exactly in the future", () => { + const invitation: Invitation = { + ...baseInvitation, + expiresAt: Date.now() + 1000, + }; + + expect(isInvitationValid(invitation)).toBe(true); + }); +}); diff --git a/packages/user/src/lib/__test__/seedRoles.spec.ts b/packages/user/src/lib/__test__/seedRoles.spec.ts new file mode 100644 index 000000000..1f33426c9 --- /dev/null +++ b/packages/user/src/lib/__test__/seedRoles.spec.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ROLE_ADMIN, ROLE_SUPERADMIN, ROLE_USER } from "../../constants"; +import seedRoles from "../seedRoles"; + +vi.mock("supertokens-node/recipe/userroles", () => ({ + default: { + createNewRoleOrAddPermissions: vi.fn().mockResolvedValue({ status: "OK" }), + }, +})); + +import UserRoles from "supertokens-node/recipe/userroles"; + +const mockCreate = vi.mocked(UserRoles.createNewRoleOrAddPermissions); + +describe("seedRoles", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("seeds ADMIN, SUPERADMIN, and USER by default", async () => { + await seedRoles(); + + const seededRoles = mockCreate.mock.calls.map(([role]) => role); + + expect(seededRoles).toContain(ROLE_ADMIN); + expect(seededRoles).toContain(ROLE_SUPERADMIN); + expect(seededRoles).toContain(ROLE_USER); + }); + + it("seeds exactly the three default roles when no extras are configured", async () => { + await seedRoles(); + + expect(mockCreate).toHaveBeenCalledTimes(3); + }); + + it("seeds custom roles in addition to the three defaults", async () => { + await seedRoles({ roles: ["MODERATOR", "EDITOR"] }); + + const seededRoles = mockCreate.mock.calls.map(([role]) => role); + + expect(seededRoles).toContain("MODERATOR"); + expect(seededRoles).toContain("EDITOR"); + expect(seededRoles).toHaveLength(5); // 3 defaults + 2 custom + }); + + it("seeds only the three defaults when config.roles is an empty array", async () => { + await seedRoles({ roles: [] }); + + expect(mockCreate).toHaveBeenCalledTimes(3); + }); + + it("seeds only the three defaults when userConfig is undefined", async () => { + await seedRoles(); + + expect(mockCreate).toHaveBeenCalledTimes(3); + }); + + it("creates each role with an empty permissions array", async () => { + await seedRoles(); + + for (const [, permissions] of mockCreate.mock.calls) { + expect(permissions).toEqual([]); + } + }); +}); diff --git a/packages/user/src/lib/computeInvitationExpiresAt.ts b/packages/user/src/lib/computeInvitationExpiresAt.ts index bfabd557c..635a5af68 100644 --- a/packages/user/src/lib/computeInvitationExpiresAt.ts +++ b/packages/user/src/lib/computeInvitationExpiresAt.ts @@ -1,9 +1,9 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + import { formatDate } from "@prefabs.tech/fastify-slonik"; import { INVITATION_EXPIRE_AFTER_IN_DAYS } from "../constants"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; - const computeInvitationExpiresAt = (config: ApiConfig, expireTime?: string) => { return ( expireTime || diff --git a/packages/user/src/lib/getInvitationLink.ts b/packages/user/src/lib/getInvitationLink.ts index 1a644483e..beeb9ce7d 100644 --- a/packages/user/src/lib/getInvitationLink.ts +++ b/packages/user/src/lib/getInvitationLink.ts @@ -1,7 +1,8 @@ -import { INVITATION_ACCEPT_LINK_PATH } from "../constants"; +import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Invitation } from "../types/invitation"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +import { INVITATION_ACCEPT_LINK_PATH } from "../constants"; const getInvitationLink = ( config: ApiConfig, diff --git a/packages/user/src/lib/getInvitationService.ts b/packages/user/src/lib/getInvitationService.ts index 89c649c61..d33906907 100644 --- a/packages/user/src/lib/getInvitationService.ts +++ b/packages/user/src/lib/getInvitationService.ts @@ -1,8 +1,8 @@ -import InvitationService from "../model/invitations/service"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Database } from "@prefabs.tech/fastify-slonik"; +import InvitationService from "../model/invitations/service"; + const getInvitationService = ( config: ApiConfig, slonik: Database, diff --git a/packages/user/src/lib/getUserService.ts b/packages/user/src/lib/getUserService.ts index 6ae6319bb..4437a63f7 100644 --- a/packages/user/src/lib/getUserService.ts +++ b/packages/user/src/lib/getUserService.ts @@ -1,8 +1,8 @@ -import UserService from "../model/users/service"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Database } from "@prefabs.tech/fastify-slonik"; +import UserService from "../model/users/service"; + const getUserService = ( config: ApiConfig, slonik: Database, diff --git a/packages/user/src/lib/hasUserPermission.ts b/packages/user/src/lib/hasUserPermission.ts index 46be56221..5c507ae35 100644 --- a/packages/user/src/lib/hasUserPermission.ts +++ b/packages/user/src/lib/hasUserPermission.ts @@ -1,9 +1,9 @@ +import type { FastifyInstance } from "fastify"; + import UserRoles from "supertokens-node/recipe/userroles"; import { ROLE_SUPERADMIN } from "../constants"; -import type { FastifyInstance } from "fastify"; - const getPermissions = async (roles: string[]) => { let permissions: string[] = []; diff --git a/packages/user/src/lib/seedRoles.ts b/packages/user/src/lib/seedRoles.ts index 5ca23899e..ab53de021 100644 --- a/packages/user/src/lib/seedRoles.ts +++ b/packages/user/src/lib/seedRoles.ts @@ -3,7 +3,7 @@ import UserRoles from "supertokens-node/recipe/userroles"; import { ROLE_ADMIN, ROLE_SUPERADMIN, ROLE_USER } from "../constants"; import { UserConfig } from "../types"; -const seedRoles = async (userConfig?: UserConfig) => { +const seedRoles = async (userConfig?: Partial) => { const roles = [ ROLE_ADMIN, ROLE_SUPERADMIN, diff --git a/packages/user/src/lib/sendEmail.ts b/packages/user/src/lib/sendEmail.ts index 381559c14..2b1a9e283 100644 --- a/packages/user/src/lib/sendEmail.ts +++ b/packages/user/src/lib/sendEmail.ts @@ -20,12 +20,12 @@ const sendEmail = async ({ return mailer .sendMail({ subject: subject, - templateName: templateName, - to: to, templateData: { appName: config.appName, ...templateData, }, + templateName: templateName, + to: to, }) .catch((error: Error) => { log.error(error.stack); diff --git a/packages/user/src/lib/sendInvitation.ts b/packages/user/src/lib/sendInvitation.ts index ac462ee5f..cf958fd66 100644 --- a/packages/user/src/lib/sendInvitation.ts +++ b/packages/user/src/lib/sendInvitation.ts @@ -1,10 +1,11 @@ +import type { FastifyInstance } from "fastify"; + +import type { Invitation } from "../types/invitation"; + import getInvitationLink from "./getInvitationLink"; import getOrigin from "./getOrigin"; import sendEmail from "./sendEmail"; -import type { Invitation } from "../types/invitation"; -import type { FastifyInstance } from "fastify"; - const sendInvitation = async ( fastify: FastifyInstance, invitation: Invitation, @@ -24,8 +25,8 @@ const sendInvitation = async ( config.user.emailOverrides?.invitation?.subject || "Invitation for sign up", templateData: { - invitationLink: getInvitationLink(config, invitation, origin), invitation, + invitationLink: getInvitationLink(config, invitation, origin), }, templateName: config.user.emailOverrides?.invitation?.templateName || diff --git a/packages/user/src/mercurius-auth/authPlugin.ts b/packages/user/src/mercurius-auth/authPlugin.ts index 34ab9cb54..5b91a5662 100644 --- a/packages/user/src/mercurius-auth/authPlugin.ts +++ b/packages/user/src/mercurius-auth/authPlugin.ts @@ -1,3 +1,5 @@ +import type { FastifyInstance } from "fastify"; + import FastifyPlugin from "fastify-plugin"; import { mercurius } from "mercurius"; import mercuriusAuth from "mercurius-auth"; @@ -7,8 +9,6 @@ import { Error } from "supertokens-node/recipe/session"; import createUserContext from "../supertokens/utils/createUserContext"; import ProfileValidationClaim from "../supertokens/utils/profileValidationClaim"; -import type { FastifyInstance } from "fastify"; - const plugin = FastifyPlugin(async (fastify: FastifyInstance) => { await fastify.register(mercuriusAuth, { async applyPolicy(authDirectiveAST, parent, arguments_, context) { @@ -39,9 +39,9 @@ const plugin = FastifyPlugin(async (fastify: FastifyInstance) => { { id: "st-ev", reason: { - message: "wrong value", - expectedValue: true, actualValue: false, + expectedValue: true, + message: "wrong value", }, }, ], diff --git a/packages/user/src/mercurius-auth/hasPermissionPlugin.ts b/packages/user/src/mercurius-auth/hasPermissionPlugin.ts index 9057afda4..96549e38b 100644 --- a/packages/user/src/mercurius-auth/hasPermissionPlugin.ts +++ b/packages/user/src/mercurius-auth/hasPermissionPlugin.ts @@ -1,11 +1,11 @@ +import type { FastifyInstance } from "fastify"; + import FastifyPlugin from "fastify-plugin"; import { mercurius } from "mercurius"; import mercuriusAuth from "mercurius-auth"; import hasUserPermission from "../lib/hasUserPermission"; -import type { FastifyInstance } from "fastify"; - const plugin = FastifyPlugin(async (fastify: FastifyInstance) => { await fastify.register(mercuriusAuth, { applyPolicy: async (authDirectiveAST, parent, arguments_, context) => { @@ -34,8 +34,8 @@ const plugin = FastifyPlugin(async (fastify: FastifyInstance) => { { id: "st-perm", reason: { - message: "Not have enough permission", expectedToInclude: permission, + message: "Not have enough permission", }, }, ], diff --git a/packages/user/src/mercurius-auth/plugin.ts b/packages/user/src/mercurius-auth/plugin.ts index 9ec865357..55ba67962 100644 --- a/packages/user/src/mercurius-auth/plugin.ts +++ b/packages/user/src/mercurius-auth/plugin.ts @@ -1,10 +1,10 @@ +import type { FastifyInstance } from "fastify"; + import FastifyPlugin from "fastify-plugin"; import authPlugin from "./authPlugin"; import hasPermissionPlugin from "./hasPermissionPlugin"; -import type { FastifyInstance } from "fastify"; - const plugin = FastifyPlugin(async (fastify: FastifyInstance) => { if (fastify.config.graphql?.enabled) { await fastify.register(hasPermissionPlugin); diff --git a/packages/user/src/middlewares/__test__/hasPermission.spec.ts b/packages/user/src/middlewares/__test__/hasPermission.spec.ts new file mode 100644 index 000000000..a6494d198 --- /dev/null +++ b/packages/user/src/middlewares/__test__/hasPermission.spec.ts @@ -0,0 +1,92 @@ +import type { SessionRequest } from "supertokens-node/framework/fastify"; + +import Fastify, { type FastifyInstance } from "fastify"; +import { Error as STError } from "supertokens-node/recipe/session"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import hasPermission from "../hasPermission"; + +const { mockHasUserPermission } = vi.hoisted(() => ({ + mockHasUserPermission: vi.fn(), +})); + +vi.mock("../../lib/hasUserPermission", () => ({ + default: mockHasUserPermission, +})); + +const buildRequest = ( + fastify: FastifyInstance, + user?: { id: string }, +): SessionRequest => { + return { + server: fastify, + user, + } as unknown as SessionRequest; +}; + +describe("hasPermission middleware", () => { + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("throws UNAUTHORISED when request has no authenticated user", async () => { + const preHandler = hasPermission("users:list"); + + await expect(preHandler(buildRequest(fastify))).rejects.toMatchObject({ + message: "unauthorised", + type: "UNAUTHORISED", + }); + }); + + it("throws INVALID_CLAIMS when user is missing permission", async () => { + mockHasUserPermission.mockResolvedValue(false); + const permission = "users:disable"; + const preHandler = hasPermission(permission); + + await expect( + preHandler(buildRequest(fastify, { id: "user-1" })), + ).rejects.toMatchObject({ + message: "Not have enough permission", + payload: [ + { + reason: { + expectedToInclude: permission, + message: "Not have enough permission", + }, + }, + ], + type: "INVALID_CLAIMS", + }); + expect(mockHasUserPermission.mock.calls.length).toBe(1); + expect(mockHasUserPermission.mock.calls[0]?.[1]).toBe("user-1"); + expect(mockHasUserPermission.mock.calls[0]?.[2]).toBe(permission); + }); + + it("allows request when user has required permission", async () => { + mockHasUserPermission.mockResolvedValue(true); + const preHandler = hasPermission("users:read"); + + await expect( + preHandler(buildRequest(fastify, { id: "user-42" })), + ).resolves.toBeUndefined(); + expect(mockHasUserPermission.mock.calls.length).toBe(1); + expect(mockHasUserPermission.mock.calls[0]?.[1]).toBe("user-42"); + expect(mockHasUserPermission.mock.calls[0]?.[2]).toBe("users:read"); + }); + + it("throws SuperTokens errors for unauthorized outcomes", async () => { + mockHasUserPermission.mockResolvedValue(false); + const preHandler = hasPermission("roles:update"); + + await expect( + preHandler(buildRequest(fastify, { id: "user-7" })), + ).rejects.toBeInstanceOf(STError); + }); +}); diff --git a/packages/user/src/middlewares/hasPermission.ts b/packages/user/src/middlewares/hasPermission.ts index 2873d0506..d7510b7f2 100644 --- a/packages/user/src/middlewares/hasPermission.ts +++ b/packages/user/src/middlewares/hasPermission.ts @@ -1,10 +1,10 @@ +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { Error as STError } from "supertokens-node/recipe/session"; import UserRoles from "supertokens-node/recipe/userroles"; import hasUserPermission from "../lib/hasUserPermission"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const hasPermission = (permission: string) => async (request: SessionRequest): Promise => { @@ -12,25 +12,25 @@ const hasPermission = if (!user) { throw new STError({ - type: "UNAUTHORISED", message: "unauthorised", + type: "UNAUTHORISED", }); } if (!(await hasUserPermission(request.server, user.id, permission))) { // this error tells SuperTokens to return a 403 http response. throw new STError({ - type: "INVALID_CLAIMS", message: "Not have enough permission", payload: [ { id: UserRoles.PermissionClaim.key, reason: { - message: "Not have enough permission", expectedToInclude: permission, + message: "Not have enough permission", }, }, ], + type: "INVALID_CLAIMS", }); } }; diff --git a/packages/user/src/migrations/queries.ts b/packages/user/src/migrations/queries.ts index b493132ef..d94c0a698 100644 --- a/packages/user/src/migrations/queries.ts +++ b/packages/user/src/migrations/queries.ts @@ -1,12 +1,12 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { QuerySqlToken } from "slonik"; +import type { ZodTypeAny } from "zod"; + import { TABLE_FILES } from "@prefabs.tech/fastify-s3"; import { sql } from "slonik"; import { TABLE_INVITATIONS, TABLE_USERS } from "../constants"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; -import type { QuerySqlToken } from "slonik"; -import type { ZodTypeAny } from "zod"; - const createInvitationsTableQuery = ( config: ApiConfig, ): QuerySqlToken => { diff --git a/packages/user/src/migrations/runMigrations.ts b/packages/user/src/migrations/runMigrations.ts index af45207d6..a79c9c464 100644 --- a/packages/user/src/migrations/runMigrations.ts +++ b/packages/user/src/migrations/runMigrations.ts @@ -1,8 +1,8 @@ -import { createInvitationsTableQuery, createUsersTableQuery } from "./queries"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Database } from "@prefabs.tech/fastify-slonik"; +import { createInvitationsTableQuery, createUsersTableQuery } from "./queries"; + const runMigrations = async (config: ApiConfig, database: Database) => { await database.connect(async (connection) => { await connection.transaction(async (transactionConnection) => { diff --git a/packages/user/src/model/invitations/controller.ts b/packages/user/src/model/invitations/controller.ts index 3eb5417e6..14795e16c 100644 --- a/packages/user/src/model/invitations/controller.ts +++ b/packages/user/src/model/invitations/controller.ts @@ -1,13 +1,5 @@ -import handlers from "./handlers"; -import { - acceptInvitationSchema, - createInvitationSchema, - deleteInvitationSchema, - getInvitationByTokenSchema, - getInvitationsListSchema, - resendInvitationSchema, - revokeInvitationSchema, -} from "./schema"; +import type { FastifyInstance } from "fastify"; + import { PERMISSIONS_INVITATIONS_CREATE, PERMISSIONS_INVITATIONS_DELETE, @@ -22,8 +14,16 @@ import { ROUTE_INVITATIONS_RESEND, ROUTE_INVITATIONS_REVOKE, } from "../../constants"; - -import type { FastifyInstance } from "fastify"; +import handlers from "./handlers"; +import { + acceptInvitationSchema, + createInvitationSchema, + deleteInvitationSchema, + getInvitationByTokenSchema, + getInvitationsListSchema, + resendInvitationSchema, + revokeInvitationSchema, +} from "./schema"; const plugin = async (fastify: FastifyInstance) => { const handlersConfig = fastify.config.user.handlers?.invitation; diff --git a/packages/user/src/model/invitations/graphql/resolver.ts b/packages/user/src/model/invitations/graphql/resolver.ts index 90962d5c4..876bd49ed 100644 --- a/packages/user/src/model/invitations/graphql/resolver.ts +++ b/packages/user/src/model/invitations/graphql/resolver.ts @@ -1,21 +1,22 @@ +import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; +import type { MercuriusContext } from "mercurius"; + import { formatDate } from "@prefabs.tech/fastify-slonik"; import { mercurius } from "mercurius"; import { createNewSession } from "supertokens-node/recipe/session"; import { emailPasswordSignUp } from "supertokens-node/recipe/thirdpartyemailpassword"; -import getInvitationService from "../../../lib/getInvitationService"; -import isInvitationValid from "../../../lib/isInvitationValid"; -import sendInvitation from "../../../lib/sendInvitation"; -import validateEmail from "../../../validator/email"; -import validatePassword from "../../../validator/password"; - import type { User } from "../../../types"; import type { Invitation, InvitationCreateInput, } from "../../../types/invitation"; -import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; -import type { MercuriusContext } from "mercurius"; + +import getInvitationService from "../../../lib/getInvitationService"; +import isInvitationValid from "../../../lib/isInvitationValid"; +import sendInvitation from "../../../lib/sendInvitation"; +import validateEmail from "../../../validator/email"; +import validatePassword from "../../../validator/password"; const Mutation = { acceptInvitation: async ( @@ -31,7 +32,7 @@ const Mutation = { ) => { const { app, config, database, dbSchema, reply } = context; - const { token, data } = arguments_; + const { data, token } = arguments_; try { const { email, password } = data; @@ -82,8 +83,8 @@ const Mutation = { // signup const signUpResponse = await emailPasswordSignUp(email, password, { - roles: [invitation.role], autoVerifyEmail: true, + roles: [invitation.role], }); if (signUpResponse.status !== "OK") { @@ -192,6 +193,35 @@ const Mutation = { ); } }, + deleteInvitation: async ( + parent: unknown, + arguments_: { + id: number; + }, + context: MercuriusContext, + ) => { + const service = getInvitationService( + context.config, + context.database, + context.dbSchema, + ); + + const invitation = await service.delete(arguments_.id); + + let errorMessage: string | undefined; + + if (!invitation) { + errorMessage = "Invitation not found"; + } + + if (errorMessage) { + const mercuriusError = new mercurius.ErrorWithProps(errorMessage); + + return mercuriusError; + } + + return invitation; + }, resendInvitation: async ( parent: unknown, arguments_: { @@ -263,35 +293,6 @@ const Mutation = { revokedAt: formatDate(new Date(Date.now())), }); - return invitation; - }, - deleteInvitation: async ( - parent: unknown, - arguments_: { - id: number; - }, - context: MercuriusContext, - ) => { - const service = getInvitationService( - context.config, - context.database, - context.dbSchema, - ); - - const invitation = await service.delete(arguments_.id); - - let errorMessage: string | undefined; - - if (!invitation) { - errorMessage = "Invitation not found"; - } - - if (errorMessage) { - const mercuriusError = new mercurius.ErrorWithProps(errorMessage); - - return mercuriusError; - } - return invitation; }, }; @@ -327,9 +328,9 @@ const Query = { invitations: async ( parent: unknown, arguments_: { + filters?: FilterInput; limit: number; offset: number; - filters?: FilterInput; sort?: SortInput[]; }, context: MercuriusContext, diff --git a/packages/user/src/model/invitations/handlers/acceptInvitation.ts b/packages/user/src/model/invitations/handlers/acceptInvitation.ts index 59d603376..eb0fdfa4a 100644 --- a/packages/user/src/model/invitations/handlers/acceptInvitation.ts +++ b/packages/user/src/model/invitations/handlers/acceptInvitation.ts @@ -1,15 +1,16 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; + import { formatDate } from "@prefabs.tech/fastify-slonik"; import { createNewSession } from "supertokens-node/recipe/session"; import { emailPasswordSignUp } from "supertokens-node/recipe/thirdpartyemailpassword"; +import type { User } from "../../../types"; + import getInvitationService from "../../../lib/getInvitationService"; import isInvitationValid from "../../../lib/isInvitationValid"; import validateEmail from "../../../validator/email"; import validatePassword from "../../../validator/password"; -import type { User } from "../../../types"; -import type { FastifyReply, FastifyRequest } from "fastify"; - interface FieldInput { email: string; password: string; @@ -66,8 +67,8 @@ const acceptInvitation = async ( // signup const signUpResponse = await emailPasswordSignUp(email, password, { - roles: [invitation.role], autoVerifyEmail: true, + roles: [invitation.role], }); if (signUpResponse.status !== "OK") { diff --git a/packages/user/src/model/invitations/handlers/createInvitation.ts b/packages/user/src/model/invitations/handlers/createInvitation.ts index 6de4e043b..08e31b378 100644 --- a/packages/user/src/model/invitations/handlers/createInvitation.ts +++ b/packages/user/src/model/invitations/handlers/createInvitation.ts @@ -1,12 +1,13 @@ -import getInvitationService from "../../../lib/getInvitationService"; -import sendInvitation from "../../../lib/sendInvitation"; +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; import type { Invitation, InvitationCreateInput, } from "../../../types/invitation"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; + +import getInvitationService from "../../../lib/getInvitationService"; +import sendInvitation from "../../../lib/sendInvitation"; const createInvitation = async ( request: SessionRequest, diff --git a/packages/user/src/model/invitations/handlers/deleteInvitation.ts b/packages/user/src/model/invitations/handlers/deleteInvitation.ts index c013d8ee2..c90133c1f 100644 --- a/packages/user/src/model/invitations/handlers/deleteInvitation.ts +++ b/packages/user/src/model/invitations/handlers/deleteInvitation.ts @@ -1,9 +1,10 @@ -import Service from "../service"; - -import type { Invitation } from "../../../types/invitation"; import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { Invitation } from "../../../types/invitation"; + +import Service from "../service"; + const deleteInvitation = async ( request: SessionRequest, reply: FastifyReply, diff --git a/packages/user/src/model/invitations/handlers/getInvitationByToken.ts b/packages/user/src/model/invitations/handlers/getInvitationByToken.ts index a250bad2d..94e51b7ee 100644 --- a/packages/user/src/model/invitations/handlers/getInvitationByToken.ts +++ b/packages/user/src/model/invitations/handlers/getInvitationByToken.ts @@ -1,7 +1,7 @@ -import getInvitationService from "../../../lib/getInvitationService"; - import type { FastifyReply, FastifyRequest } from "fastify"; +import getInvitationService from "../../../lib/getInvitationService"; + const getInvitationByToken = async ( request: FastifyRequest, reply: FastifyReply, diff --git a/packages/user/src/model/invitations/handlers/listInvitation.ts b/packages/user/src/model/invitations/handlers/listInvitation.ts index 3beeeaf5e..45ede5370 100644 --- a/packages/user/src/model/invitations/handlers/listInvitation.ts +++ b/packages/user/src/model/invitations/handlers/listInvitation.ts @@ -1,17 +1,18 @@ -import getInvitationService from "../../../lib/getInvitationService"; - -import type { Invitation } from "../../../types/invitation"; import type { PaginatedList } from "@prefabs.tech/fastify-slonik"; import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { Invitation } from "../../../types/invitation"; + +import getInvitationService from "../../../lib/getInvitationService"; + const listInvitation = async (request: SessionRequest, reply: FastifyReply) => { const { config, dbSchema, query, slonik } = request; - const { limit, offset, filters, sort } = query as { + const { filters, limit, offset, sort } = query as { + filters?: string; limit: number; offset?: number; - filters?: string; sort?: string; }; diff --git a/packages/user/src/model/invitations/handlers/resendInvitation.ts b/packages/user/src/model/invitations/handlers/resendInvitation.ts index de6396a54..09c1c1fb7 100644 --- a/packages/user/src/model/invitations/handlers/resendInvitation.ts +++ b/packages/user/src/model/invitations/handlers/resendInvitation.ts @@ -1,16 +1,17 @@ +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + +import type { Invitation } from "../../../types/invitation"; + import getInvitationService from "../../../lib/getInvitationService"; import isInvitationValid from "../../../lib/isInvitationValid"; import sendInvitation from "../../../lib/sendInvitation"; -import type { Invitation } from "../../../types/invitation"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const resendInvitation = async ( request: SessionRequest, reply: FastifyReply, ) => { - const { config, dbSchema, headers, hostname, log, params, slonik, server } = + const { config, dbSchema, headers, hostname, log, params, server, slonik } = request; const { id } = params as { id: string }; @@ -22,8 +23,8 @@ const resendInvitation = async ( // is invitation valid if (!invitation || !isInvitationValid(invitation)) { return reply.send({ - status: "ERROR", message: "Invitation is invalid or has expired", + status: "ERROR", }); } diff --git a/packages/user/src/model/invitations/handlers/revokeInvitation.ts b/packages/user/src/model/invitations/handlers/revokeInvitation.ts index e4869fa5f..b39fccf3f 100644 --- a/packages/user/src/model/invitations/handlers/revokeInvitation.ts +++ b/packages/user/src/model/invitations/handlers/revokeInvitation.ts @@ -1,10 +1,11 @@ -import { formatDate } from "@prefabs.tech/fastify-slonik"; +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; -import getInvitationService from "../../../lib/getInvitationService"; +import { formatDate } from "@prefabs.tech/fastify-slonik"; import type { Invitation } from "../../../types/invitation"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; + +import getInvitationService from "../../../lib/getInvitationService"; const revokeInvitation = async ( request: SessionRequest, diff --git a/packages/user/src/model/invitations/schema.ts b/packages/user/src/model/invitations/schema.ts index 7f08d4313..7471e996c 100644 --- a/packages/user/src/model/invitations/schema.ts +++ b/packages/user/src/model/invitations/schema.ts @@ -1,41 +1,40 @@ const invitationCreateInputSchema = { - type: "object", properties: { - appId: { type: "integer", nullable: true }, - email: { type: "string", format: "email" }, - payload: { type: "object", additionalProperties: true, nullable: true }, + appId: { nullable: true, type: "integer" }, + email: { format: "email", type: "string" }, + payload: { additionalProperties: true, nullable: true, type: "object" }, role: { type: "string" }, }, required: ["email", "role"], + type: "object", }; const userSchema = { - type: "object", + additionalProperties: true, properties: { + email: { format: "email", type: "string" }, id: { type: "string" }, - email: { type: "string", format: "email" }, }, - additionalProperties: true, + type: "object", }; const invitationSchema = { - type: "object", properties: { - id: { type: "integer" }, - acceptedAt: { type: "integer", nullable: true }, - appId: { type: "integer", nullable: true }, - email: { type: "string", format: "email" }, + acceptedAt: { nullable: true, type: "integer" }, + appId: { nullable: true, type: "integer" }, + createdAt: { type: "integer" }, + email: { format: "email", type: "string" }, expiresAt: { type: "integer" }, + id: { type: "integer" }, invitedBy: { ...userSchema, nullable: true, }, invitedById: { type: "string" }, - payload: { type: "object", additionalProperties: true, nullable: true }, - revokedAt: { type: "integer", nullable: true }, + payload: { additionalProperties: true, nullable: true, type: "object" }, + revokedAt: { nullable: true, type: "integer" }, role: { type: "string" }, token: { type: "string" }, - createdAt: { type: "integer" }, updatedAt: { type: "integer" }, }, required: [ @@ -47,45 +46,46 @@ const invitationSchema = { "createdAt", "updatedAt", ], + type: "object", }; export const acceptInvitationSchema = { - description: "Accept an invitation using the invitation token", - operationId: "acceptInvitation", body: { - type: "object", properties: { - email: { type: "string", format: "email" }, - password: { type: "string", format: "password" }, + email: { format: "email", type: "string" }, + password: { format: "password", type: "string" }, }, required: ["email", "password"], + type: "object", }, + description: "Accept an invitation using the invitation token", + operationId: "acceptInvitation", params: { - type: "object", properties: { token: { type: "string" }, }, required: ["token"], + type: "object", }, response: { 200: { - type: "object", properties: { status: { type: "string" }, user: userSchema, }, + type: "object", }, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 500: { $ref: "ErrorResponse#", @@ -95,22 +95,22 @@ export const acceptInvitationSchema = { }; export const createInvitationSchema = { + body: invitationCreateInputSchema, description: "Create a new invitation", operationId: "createInvitation", - body: invitationCreateInputSchema, response: { 200: invitationSchema, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 500: { $ref: "ErrorResponse#", @@ -123,29 +123,29 @@ export const deleteInvitationSchema = { description: "Delete an invitation by ID", operationId: "deleteInvitation", params: { - type: "object", - required: ["id"], properties: { id: { type: "integer" }, }, + required: ["id"], + type: "object", }, response: { 200: invitationSchema, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 404: { - description: "Invitation not found", $ref: "ErrorResponse#", + description: "Invitation not found", }, 500: { $ref: "ErrorResponse#", @@ -158,11 +158,11 @@ export const getInvitationByTokenSchema = { description: "Get invitation details by token", operationId: "getInvitationByToken", params: { - type: "object", - required: ["token"], properties: { token: { type: "string" }, }, + required: ["token"], + type: "object", }, response: { 200: { @@ -170,16 +170,16 @@ export const getInvitationByTokenSchema = { nullable: true, }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 404: { - description: "Invitation not found", $ref: "ErrorResponse#", + description: "Invitation not found", }, 500: { $ref: "ErrorResponse#", @@ -192,35 +192,35 @@ export const getInvitationsListSchema = { description: "Get a paginated list of invitations", operationId: "getInvitationsList", querystring: { - type: "object", properties: { + filters: { type: "string" }, limit: { type: "number" }, offset: { type: "number" }, - filters: { type: "string" }, sort: { type: "string" }, }, + type: "object", }, response: { 200: { description: "List of paginated list of invitations", - type: "object", - required: ["totalCount", "filteredCount", "data"], properties: { - totalCount: { type: "integer" }, - filteredCount: { type: "integer" }, data: { - type: "array", items: invitationSchema, + type: "array", }, + filteredCount: { type: "integer" }, + totalCount: { type: "integer" }, }, + required: ["totalCount", "filteredCount", "data"], + type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 500: { $ref: "ErrorResponse#", @@ -233,40 +233,40 @@ export const resendInvitationSchema = { description: "Resend an invitation by ID", operationId: "resendInvitation", params: { - type: "object", - required: ["id"], properties: { id: { type: "integer" }, }, + required: ["id"], + type: "object", }, response: { 200: { oneOf: [ invitationSchema, { - type: "object", properties: { - status: { type: "string", const: "ERROR" }, message: { type: "string" }, + status: { const: "ERROR", type: "string" }, }, + type: "object", }, ], }, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 404: { - description: "Invitation not found", $ref: "ErrorResponse#", + description: "Invitation not found", }, 500: { $ref: "ErrorResponse#", @@ -279,29 +279,29 @@ export const revokeInvitationSchema = { description: "Revoke an invitation by ID", operationId: "revokeInvitation", params: { - type: "object", - required: ["id"], properties: { id: { type: "integer" }, }, + required: ["id"], + type: "object", }, response: { 200: invitationSchema, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 404: { - description: "Invitation not found", $ref: "ErrorResponse#", + description: "Invitation not found", }, 500: { $ref: "ErrorResponse#", @@ -311,35 +311,35 @@ export const revokeInvitationSchema = { }; export const updateInvitationSchema = { - description: "Update an invitation", - operationId: "updateInvitation", body: { - type: "object", - required: ["email", "status"], properties: { - email: { type: "string", format: "email" }, - status: { type: "string", enum: ["accepted", "declined"] }, + email: { format: "email", type: "string" }, + status: { enum: ["accepted", "declined"], type: "string" }, }, + required: ["email", "status"], + type: "object", }, + description: "Update an invitation", + operationId: "updateInvitation", response: { 200: { description: "Invitation updated successfully", - type: "object", properties: { status: { type: "string" }, }, + type: "object", }, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 500: { $ref: "ErrorResponse#", diff --git a/packages/user/src/model/invitations/service.ts b/packages/user/src/model/invitations/service.ts index 817ecc409..8f07ee624 100644 --- a/packages/user/src/model/invitations/service.ts +++ b/packages/user/src/model/invitations/service.ts @@ -1,25 +1,34 @@ -import { CustomError } from "@prefabs.tech/fastify-error-handler"; -import { formatDate, BaseService } from "@prefabs.tech/fastify-slonik"; +import type { FilterInput } from "@prefabs.tech/fastify-slonik"; -import InvitationSqlFactory from "./sqlFactory"; -import { ERROR_CODES } from "../../constants"; -import computeInvitationExpiresAt from "../../lib/computeInvitationExpiresAt"; -import getUserService from "../../lib/getUserService"; -import areRolesExist from "../../supertokens/utils/areRolesExist"; -import validateEmail from "../../validator/email"; +import { CustomError } from "@prefabs.tech/fastify-error-handler"; +import { BaseService, formatDate } from "@prefabs.tech/fastify-slonik"; import type { Invitation, InvitationCreateInput, InvitationUpdateInput, } from "../../types"; -import type { FilterInput } from "@prefabs.tech/fastify-slonik"; + +import { ERROR_CODES } from "../../constants"; +import computeInvitationExpiresAt from "../../lib/computeInvitationExpiresAt"; +import getUserService from "../../lib/getUserService"; +import areRolesExist from "../../supertokens/utils/areRolesExist"; +import validateEmail from "../../validator/email"; +import InvitationSqlFactory from "./sqlFactory"; class InvitationService extends BaseService< Invitation, InvitationCreateInput, InvitationUpdateInput > { + get factory(): InvitationSqlFactory { + return super.factory as InvitationSqlFactory; + } + + get sqlFactoryClass() { + return InvitationSqlFactory; + } + async findByToken(token: string): Promise { if (!this.validateUUID(token)) { // eslint-disable-next-line unicorn/no-null @@ -35,14 +44,6 @@ class InvitationService extends BaseService< return result; } - get factory(): InvitationSqlFactory { - return super.factory as InvitationSqlFactory; - } - - get sqlFactoryClass() { - return InvitationSqlFactory; - } - protected async preCreate( data: InvitationCreateInput, ): Promise { diff --git a/packages/user/src/model/invitations/sqlFactory.ts b/packages/user/src/model/invitations/sqlFactory.ts index 0f47ceffd..62ef70237 100644 --- a/packages/user/src/model/invitations/sqlFactory.ts +++ b/packages/user/src/model/invitations/sqlFactory.ts @@ -1,15 +1,19 @@ +import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; +import type { FragmentSqlToken, QuerySqlToken } from "slonik"; + import { DefaultSqlFactory } from "@prefabs.tech/fastify-slonik"; import { sql } from "slonik"; import { TABLE_INVITATIONS } from "../../constants"; import UserSqlFactory from "../users/sqlFactory"; -import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; -import type { FragmentSqlToken, QuerySqlToken } from "slonik"; - class InvitationSqlFactory extends DefaultSqlFactory { static readonly TABLE = TABLE_INVITATIONS; + get table() { + return this.config.user?.tables?.invitations?.name || super.table; + } + getFindByTokenSql = (token: string): QuerySqlToken => { return sql.type(this.validationSchema)` SELECT * @@ -43,10 +47,6 @@ class InvitationSqlFactory extends DefaultSqlFactory { return userSqlFactory.tableFragment; } - - get table() { - return this.config.user?.tables?.invitations?.name || super.table; - } } export default InvitationSqlFactory; diff --git a/packages/user/src/model/permissions/controller.ts b/packages/user/src/model/permissions/controller.ts index 97f26e5dd..f7bd416fd 100644 --- a/packages/user/src/model/permissions/controller.ts +++ b/packages/user/src/model/permissions/controller.ts @@ -1,8 +1,8 @@ +import type { FastifyInstance } from "fastify"; + +import { ROUTE_PERMISSIONS } from "../../constants"; import handlers from "./handlers"; import { getPermissionsSchema } from "./schema"; -import { ROUTE_PERMISSIONS } from "../../constants"; - -import type { FastifyInstance } from "fastify"; const plugin = async (fastify: FastifyInstance) => { fastify.get( diff --git a/packages/user/src/model/permissions/resolver.ts b/packages/user/src/model/permissions/resolver.ts index 52011d720..002d8f0c1 100644 --- a/packages/user/src/model/permissions/resolver.ts +++ b/packages/user/src/model/permissions/resolver.ts @@ -1,7 +1,7 @@ -import { mercurius } from "mercurius"; - import type { MercuriusContext } from "mercurius"; +import { mercurius } from "mercurius"; + const Query = { permissions: async ( parent: unknown, diff --git a/packages/user/src/model/permissions/schema.ts b/packages/user/src/model/permissions/schema.ts index 12de22465..0f87301de 100644 --- a/packages/user/src/model/permissions/schema.ts +++ b/packages/user/src/model/permissions/schema.ts @@ -3,23 +3,23 @@ export const getPermissionsSchema = { operationId: "getPermissions", response: { 200: { - type: "object", properties: { permissions: { - type: "array", items: { type: "string", }, + type: "array", }, }, + type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 500: { $ref: "ErrorResponse#", diff --git a/packages/user/src/model/roles/controller.ts b/packages/user/src/model/roles/controller.ts index 557869362..b41971f49 100644 --- a/packages/user/src/model/roles/controller.ts +++ b/packages/user/src/model/roles/controller.ts @@ -1,3 +1,6 @@ +import type { FastifyInstance } from "fastify"; + +import { ROUTE_ROLES, ROUTE_ROLES_PERMISSIONS } from "../../constants"; import handlers from "./handlers"; import { createRoleSchema, @@ -6,9 +9,6 @@ import { getRolesSchema, updateRoleSchema, } from "./schema"; -import { ROUTE_ROLES, ROUTE_ROLES_PERMISSIONS } from "../../constants"; - -import type { FastifyInstance } from "fastify"; const plugin = async (fastify: FastifyInstance) => { fastify.delete( diff --git a/packages/user/src/model/roles/graphql/resolver.ts b/packages/user/src/model/roles/graphql/resolver.ts index cb048811e..6523460b0 100644 --- a/packages/user/src/model/roles/graphql/resolver.ts +++ b/packages/user/src/model/roles/graphql/resolver.ts @@ -1,16 +1,16 @@ +import type { MercuriusContext } from "mercurius"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import { mercurius } from "mercurius"; import RoleService from "../service"; -import type { MercuriusContext } from "mercurius"; - const Mutation = { createRole: async ( parent: unknown, arguments_: { - role: string; permissions: string[]; + role: string; }, context: MercuriusContext, ) => { @@ -87,8 +87,8 @@ const Mutation = { updateRolePermissions: async ( parent: unknown, arguments_: { - role: string; permissions: string[]; + role: string; }, context: MercuriusContext, ) => { @@ -126,18 +126,25 @@ const Mutation = { }; const Query = { - roles: async ( + rolePermissions: async ( parent: unknown, - arguments_: Record, + arguments_: { + role: string; + }, context: MercuriusContext, ) => { const { app } = context; + const { role } = arguments_; + let permissions: string[] = []; try { - const service = new RoleService(); - const roles = await service.getRoles(); + if (role) { + const service = new RoleService(); - return roles; + permissions = await service.getPermissionsForRole(role); + } + + return permissions; } catch (error) { app.log.error(error); @@ -150,25 +157,18 @@ const Query = { return mercuriusError; } }, - rolePermissions: async ( + roles: async ( parent: unknown, - arguments_: { - role: string; - }, + arguments_: Record, context: MercuriusContext, ) => { const { app } = context; - const { role } = arguments_; - let permissions: string[] = []; try { - if (role) { - const service = new RoleService(); - - permissions = await service.getPermissionsForRole(role); - } + const service = new RoleService(); + const roles = await service.getRoles(); - return permissions; + return roles; } catch (error) { app.log.error(error); diff --git a/packages/user/src/model/roles/handlers/createRole.ts b/packages/user/src/model/roles/handlers/createRole.ts index b4c917a84..22cda0fbe 100644 --- a/packages/user/src/model/roles/handlers/createRole.ts +++ b/packages/user/src/model/roles/handlers/createRole.ts @@ -1,16 +1,16 @@ +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import RoleService from "../service"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const createRole = async (request: SessionRequest, reply: FastifyReply) => { const { body } = request; - const { role, permissions } = body as { - role: string; + const { permissions, role } = body as { permissions: string[]; + role: string; }; try { diff --git a/packages/user/src/model/roles/handlers/deleteRole.ts b/packages/user/src/model/roles/handlers/deleteRole.ts index c6f7f187f..dddd384e7 100644 --- a/packages/user/src/model/roles/handlers/deleteRole.ts +++ b/packages/user/src/model/roles/handlers/deleteRole.ts @@ -1,11 +1,11 @@ +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import { ERROR_CODES } from "../../../constants"; import RoleService from "../service"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const deleteRole = async (request: SessionRequest, reply: FastifyReply) => { const { query } = request; diff --git a/packages/user/src/model/roles/handlers/getPermissions.ts b/packages/user/src/model/roles/handlers/getPermissions.ts index b6cd1fb57..2fe21f203 100644 --- a/packages/user/src/model/roles/handlers/getPermissions.ts +++ b/packages/user/src/model/roles/handlers/getPermissions.ts @@ -1,8 +1,8 @@ -import RoleService from "../service"; - import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import RoleService from "../service"; + const getPermissions = async (request: SessionRequest, reply: FastifyReply) => { const { query } = request; let permissions: string[] = []; diff --git a/packages/user/src/model/roles/handlers/getRoles.ts b/packages/user/src/model/roles/handlers/getRoles.ts index a5a5b0b4e..40abe3db3 100644 --- a/packages/user/src/model/roles/handlers/getRoles.ts +++ b/packages/user/src/model/roles/handlers/getRoles.ts @@ -1,8 +1,8 @@ -import RoleService from "../service"; - import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import RoleService from "../service"; + const getRoles = async (request: SessionRequest, reply: FastifyReply) => { const service = new RoleService(); const roles = await service.getRoles(); diff --git a/packages/user/src/model/roles/handlers/index.ts b/packages/user/src/model/roles/handlers/index.ts index 9176aedec..d3ab61b3f 100644 --- a/packages/user/src/model/roles/handlers/index.ts +++ b/packages/user/src/model/roles/handlers/index.ts @@ -5,9 +5,9 @@ import getRoles from "./getRoles"; import updatePermissions from "./updatePermissions"; export default { - deleteRole, createRole, - getRoles, + deleteRole, getPermissions, + getRoles, updatePermissions, }; diff --git a/packages/user/src/model/roles/handlers/updatePermissions.ts b/packages/user/src/model/roles/handlers/updatePermissions.ts index 07d8884c1..8dc30eae7 100644 --- a/packages/user/src/model/roles/handlers/updatePermissions.ts +++ b/packages/user/src/model/roles/handlers/updatePermissions.ts @@ -1,10 +1,10 @@ +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import RoleService from "../service"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const updatePermissions = async ( request: SessionRequest, reply: FastifyReply, @@ -12,9 +12,9 @@ const updatePermissions = async ( const { body } = request; try { - const { role, permissions } = body as { - role: string; + const { permissions, role } = body as { permissions: string[]; + role: string; }; const service = new RoleService(); diff --git a/packages/user/src/model/roles/schema.ts b/packages/user/src/model/roles/schema.ts index b8e44bd6e..3b9ebb0a2 100644 --- a/packages/user/src/model/roles/schema.ts +++ b/packages/user/src/model/roles/schema.ts @@ -1,36 +1,36 @@ export const createRoleSchema = { - description: "Create a new role with optional permissions", - operationId: "createRole", body: { - type: "object", - required: ["role"], properties: { - role: { type: "string" }, permissions: { - type: "array", items: { type: "string" }, + type: "array", }, + role: { type: "string" }, }, + required: ["role"], + type: "object", }, + description: "Create a new role with optional permissions", + operationId: "createRole", response: { 201: { description: "Role created successfully", - type: "object", properties: { status: { type: "string" }, }, + type: "object", }, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 500: { $ref: "ErrorResponse#", @@ -43,10 +43,10 @@ export const deleteRoleSchema = { description: "Delete a role by name", operationId: "deleteRole", querystring: { - type: "object", properties: { role: { type: "string" }, }, + type: "object", }, response: { 200: { @@ -57,16 +57,16 @@ export const deleteRoleSchema = { type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 422: { - description: "Unprocessable Entity", $ref: "ErrorResponse#", + description: "Unprocessable Entity", }, 500: { $ref: "ErrorResponse#", @@ -79,33 +79,33 @@ export const getRolePermissionsSchema = { description: "Get permissions for a specific role", operationId: "getRolePermissions", querystring: { - type: "object", properties: { role: { type: "string" }, }, + type: "object", }, response: { 200: { description: "Role permissions retrieved successfully", - type: "object", properties: { permissions: { - type: "array", items: { type: "string" }, + type: "array", }, }, + type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 404: { - description: "Role not found", $ref: "ErrorResponse#", + description: "Role not found", }, 500: { $ref: "ErrorResponse#", @@ -119,30 +119,30 @@ export const getRolesSchema = { operationId: "getRoles", response: { 200: { - type: "object", properties: { roles: { - type: "array", items: { - type: "object", properties: { - role: { type: "string" }, permissions: { - type: "array", items: { type: "string" }, + type: "array", }, + role: { type: "string" }, }, + type: "object", }, + type: "array", }, }, + type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 500: { $ref: "ErrorResponse#", @@ -152,42 +152,42 @@ export const getRolesSchema = { }; export const updateRoleSchema = { - description: "Update a role's permissions", - operationId: "updateRole", body: { - type: "object", - required: ["role"], properties: { - role: { type: "string" }, permissions: { - type: "array", items: { type: "string" }, + type: "array", }, + role: { type: "string" }, }, + required: ["role"], + type: "object", }, + description: "Update a role's permissions", + operationId: "updateRole", response: { 200: { description: "Role updated successfully", - type: "object", properties: { - status: { type: "string" }, permissions: { - type: "array", items: { type: "string" }, + type: "array", }, + status: { type: "string" }, }, + type: "object", }, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 500: { $ref: "ErrorResponse#", diff --git a/packages/user/src/model/roles/service.ts b/packages/user/src/model/roles/service.ts index 15ebb4bdd..ae1171ba3 100644 --- a/packages/user/src/model/roles/service.ts +++ b/packages/user/src/model/roles/service.ts @@ -56,8 +56,8 @@ class RoleService { return permissions; } - async getRoles(): Promise<{ role: string; permissions: string[] }[]> { - let roles: { role: string; permissions: string[] }[] = []; + async getRoles(): Promise<{ permissions: string[]; role: string }[]> { + let roles: { permissions: string[]; role: string }[] = []; const response = await UserRoles.getAllRoles(); @@ -68,8 +68,8 @@ class RoleService { const response = await UserRoles.getPermissionsForRole(role); return { - role, permissions: response.status === "OK" ? response.permissions : [], + role, }; }), ); @@ -81,7 +81,7 @@ class RoleService { async updateRolePermissions( role: string, permissions: string[], - ): Promise<{ status: "OK"; permissions: string[] }> { + ): Promise<{ permissions: string[]; status: "OK" }> { const response = await UserRoles.getPermissionsForRole(role); if (response.status === "UNKNOWN_ROLE_ERROR") { @@ -104,8 +104,8 @@ class RoleService { const permissionsResponse = await this.getPermissionsForRole(role); return { - status: "OK", permissions: permissionsResponse, + status: "OK", }; } } diff --git a/packages/user/src/model/users/__test__/filterUserUpdateInput.spec.ts b/packages/user/src/model/users/__test__/filterUserUpdateInput.spec.ts index 00b1c127f..08d050ab8 100644 --- a/packages/user/src/model/users/__test__/filterUserUpdateInput.spec.ts +++ b/packages/user/src/model/users/__test__/filterUserUpdateInput.spec.ts @@ -1,12 +1,12 @@ /* istanbul ignore file */ import { describe, expect, it } from "vitest"; -import filterUserUpdateInput from "../filterUserUpdateInput"; - import type { UserUpdateInput } from "../../../types"; -describe("removeUpdateProperties", () => { - it("should not remove valid input key", () => { +import filterUserUpdateInput from "../filterUserUpdateInput"; + +describe("filterUserUpdateInput", () => { + it("does not remove a valid mutable input key", () => { const updateInput = { middleNames: "A", } as UserUpdateInput; @@ -16,17 +16,57 @@ describe("removeUpdateProperties", () => { expect(updateInput).toHaveProperty("middleNames"); }); - it("should remove ignored input key", () => { + it("removes 'email'", () => { + const updateInput = { email: "user@example.com" } as UserUpdateInput; + + filterUserUpdateInput(updateInput); + + expect(updateInput).not.toHaveProperty("email"); + }); + + it("removes 'id'", () => { + const updateInput = { id: "some-id" } as UserUpdateInput; + + filterUserUpdateInput(updateInput); + + expect(updateInput).not.toHaveProperty("id"); + }); + + it("removes 'roles'", () => { + const updateInput = { roles: ["ADMIN"] } as UserUpdateInput; + + filterUserUpdateInput(updateInput); + + expect(updateInput).not.toHaveProperty("roles"); + }); + + it("removes 'disable'", () => { + const updateInput = { disable: true } as UserUpdateInput; + + filterUserUpdateInput(updateInput); + + expect(updateInput).not.toHaveProperty("disable"); + }); + + it("removes 'enable'", () => { + const updateInput = { enable: true } as UserUpdateInput; + + filterUserUpdateInput(updateInput); + + expect(updateInput).not.toHaveProperty("enable"); + }); + + it("removes camelCase 'lastLoginAt'", () => { const updateInput = { - email: "user@example.com", + lastLoginAt: "2023-06-13 04:02:45.825", } as UserUpdateInput; filterUserUpdateInput(updateInput); - expect(updateInput).not.toHaveProperty("email"); + expect(updateInput).not.toHaveProperty("lastLoginAt"); }); - it("should remove ignored input (tricky) key", () => { + it("removes mixed 'lastLogin_at' (camelized to lastLoginAt)", () => { const updateInput = { lastLogin_at: "2023-06-13 04:02:45.825", } as UserUpdateInput; @@ -36,17 +76,55 @@ describe("removeUpdateProperties", () => { expect(updateInput).not.toHaveProperty("lastLogin_at"); }); - it("should handle more than one input keys", () => { + it("removes snake_case 'last_login_at' (camelized to lastLoginAt)", () => { + const updateInput = { + last_login_at: "2023-06-13 04:02:45.825", + } as UserUpdateInput; + + filterUserUpdateInput(updateInput); + + expect(updateInput).not.toHaveProperty("last_login_at"); + }); + + it("removes camelCase 'signedUpAt'", () => { + const updateInput = { signedUpAt: 1_234_567_890 } as UserUpdateInput; + + filterUserUpdateInput(updateInput); + + expect(updateInput).not.toHaveProperty("signedUpAt"); + }); + + it("removes snake_case 'signed_up_at' (camelized to signedUpAt)", () => { + const updateInput = { signed_up_at: 1_234_567_890 } as UserUpdateInput; + + filterUserUpdateInput(updateInput); + + expect(updateInput).not.toHaveProperty("signed_up_at"); + }); + + it("removes blocked fields while preserving valid fields in a mixed input", () => { const updateInput = { email: "user@example.com", - lastLogin_at: "2023-06-13 04:02:45.825", + last_login_at: "2023-06-13 04:02:45.825", middleNames: "A", + photoId: 42, } as UserUpdateInput; filterUserUpdateInput(updateInput); expect(updateInput).toHaveProperty("middleNames"); + expect(updateInput).toHaveProperty("photoId"); expect(updateInput).not.toHaveProperty("email"); - expect(updateInput).not.toHaveProperty("lastLogin_at"); + expect(updateInput).not.toHaveProperty("last_login_at"); + }); + + it("mutates the input object in place", () => { + const updateInput = { email: "user@example.com" } as UserUpdateInput; + const reference = updateInput; + + filterUserUpdateInput(updateInput); + + // Same object reference — no new object created + expect(updateInput).toBe(reference); }); }); diff --git a/packages/user/src/model/users/controller.ts b/packages/user/src/model/users/controller.ts index be5db40a0..48f81e186 100644 --- a/packages/user/src/model/users/controller.ts +++ b/packages/user/src/model/users/controller.ts @@ -1,5 +1,23 @@ +import type { FastifyInstance } from "fastify"; + import { EmailVerificationClaim } from "supertokens-node/recipe/emailverification"; +import { + PERMISSIONS_USERS_DISABLE, + PERMISSIONS_USERS_ENABLE, + PERMISSIONS_USERS_LIST, + PERMISSIONS_USERS_READ, + ROUTE_CHANGE_EMAIL, + ROUTE_CHANGE_PASSWORD, + ROUTE_ME, + ROUTE_ME_PHOTO, + ROUTE_SIGNUP_ADMIN, + ROUTE_USERS, + ROUTE_USERS_DISABLE, + ROUTE_USERS_ENABLE, + ROUTE_USERS_FIND_BY_ID, +} from "../../constants"; +import ProfileValidationClaim from "../../supertokens/utils/profileValidationClaim"; import handlers from "./handlers"; import { adminSignUpSchema, @@ -16,24 +34,6 @@ import { updateMeSchema, uploadPhotoSchema, } from "./schema"; -import { - PERMISSIONS_USERS_DISABLE, - PERMISSIONS_USERS_ENABLE, - PERMISSIONS_USERS_READ, - PERMISSIONS_USERS_LIST, - ROUTE_CHANGE_EMAIL, - ROUTE_CHANGE_PASSWORD, - ROUTE_SIGNUP_ADMIN, - ROUTE_ME, - ROUTE_USERS, - ROUTE_USERS_DISABLE, - ROUTE_USERS_ENABLE, - ROUTE_USERS_FIND_BY_ID, - ROUTE_ME_PHOTO, -} from "../../constants"; -import ProfileValidationClaim from "../../supertokens/utils/profileValidationClaim"; - -import type { FastifyInstance } from "fastify"; const plugin = async (fastify: FastifyInstance) => { const handlersConfig = fastify.config.user.handlers?.user; diff --git a/packages/user/src/model/users/dbFilters.ts b/packages/user/src/model/users/dbFilters.ts index 8463b9cce..d2f633aa3 100644 --- a/packages/user/src/model/users/dbFilters.ts +++ b/packages/user/src/model/users/dbFilters.ts @@ -1,12 +1,12 @@ -import { applyFilter } from "@prefabs.tech/fastify-slonik"; -import humps from "humps"; -import { sql } from "slonik"; - import type { BaseFilterInput, FilterInput, } from "@prefabs.tech/fastify-slonik"; -import type { IdentifierSqlToken, FragmentSqlToken } from "slonik"; +import type { FragmentSqlToken, IdentifierSqlToken } from "slonik"; + +import { applyFilter } from "@prefabs.tech/fastify-slonik"; +import humps from "humps"; +import { sql } from "slonik"; const applyFiltersToQuery = ( filters: FilterInput, diff --git a/packages/user/src/model/users/filterUserUpdateInput.ts b/packages/user/src/model/users/filterUserUpdateInput.ts index d312c3aa0..44861ca3b 100644 --- a/packages/user/src/model/users/filterUserUpdateInput.ts +++ b/packages/user/src/model/users/filterUserUpdateInput.ts @@ -3,10 +3,10 @@ import humps from "humps"; import type { UserUpdateInput } from "../../types"; const ignoredUpdateKeys = new Set([ - "id", "disable", - "enable", "email", + "enable", + "id", "lastLoginAt", "roles", "signedUpAt", diff --git a/packages/user/src/model/users/graphql/resolver.ts b/packages/user/src/model/users/graphql/resolver.ts index 442667034..452d19ff8 100644 --- a/packages/user/src/model/users/graphql/resolver.ts +++ b/packages/user/src/model/users/graphql/resolver.ts @@ -1,3 +1,6 @@ +import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; +import type { MercuriusContext } from "mercurius"; + import { GraphQLUpload, Multipart } from "@prefabs.tech/fastify-s3"; import { mercurius } from "mercurius"; import EmailVerification, { @@ -11,6 +14,8 @@ import { } from "supertokens-node/recipe/thirdpartyemailpassword"; import UserRoles from "supertokens-node/recipe/userroles"; +import type { UserUpdateInput } from "../../../types"; + import { ROLE_ADMIN, ROLE_SUPERADMIN } from "../../../constants"; import CustomApiError from "../../../customApiError"; import getUserService from "../../../lib/getUserService"; @@ -20,10 +25,6 @@ import validateEmail from "../../../validator/email"; import validatePassword from "../../../validator/password"; import filterUserUpdateInput from "../filterUserUpdateInput"; -import type { UserUpdateInput } from "../../../types"; -import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; -import type { MercuriusContext } from "mercurius"; - const Mutation = { adminSignUp: async ( parent: unknown, @@ -89,16 +90,16 @@ const Mutation = { // signup const signUpResponse = await emailPasswordSignUp(email, password, { - autoVerifyEmail: true, - roles: [ - ROLE_ADMIN, - ...(superAdminUsers.status === "OK" ? [ROLE_SUPERADMIN] : []), - ], _default: { request: { request: reply.request, }, }, + autoVerifyEmail: true, + roles: [ + ROLE_ADMIN, + ...(superAdminUsers.status === "OK" ? [ROLE_SUPERADMIN] : []), + ], }); if (signUpResponse.status !== "OK") { @@ -126,6 +127,162 @@ const Mutation = { return mercuriusError; } }, + changeEmail: async ( + parent: unknown, + arguments_: { + email: string; + }, + context: MercuriusContext, + ) => { + const { app, config, database, dbSchema, reply, user } = context; + + try { + if (user) { + if (config.user.features?.updateEmail?.enabled === false) { + return new mercurius.ErrorWithProps("EMAIL_FEATURE_DISABLED_ERROR"); + } + + const request = reply.request; + + if (config.user.features?.profileValidation?.enabled) { + await request.session?.fetchAndSetClaim( + new ProfileValidationClaim(), + createUserContext(undefined, request), + ); + } + + if (config.user.features?.signUp?.emailVerification) { + await request.session?.fetchAndSetClaim( + EmailVerificationClaim, + createUserContext(undefined, request), + ); + } + + const emailValidationResult = validateEmail(arguments_.email, config); + + if (!emailValidationResult.success) { + return new mercurius.ErrorWithProps("EMAIL_INVALID_ERROR"); + } + + if (user.email === arguments_.email) { + return new mercurius.ErrorWithProps("EMAIL_SAME_AS_CURRENT_ERROR"); + } + + if (config.user.features?.signUp?.emailVerification) { + const isVerified = await isEmailVerified(user.id, arguments_.email); + + if (!isVerified) { + const users = await getUsersByEmail(arguments_.email); + + const emailPasswordRecipeUsers = users.filter( + (user) => !user.thirdParty, + ); + + if (emailPasswordRecipeUsers.length > 0) { + return new mercurius.ErrorWithProps("EMAIL_ALREADY_EXISTS_ERROR"); + } + + const tokenResponse = + await EmailVerification.createEmailVerificationToken( + user.id, + arguments_.email, + ); + + if (tokenResponse.status === "OK") { + await EmailVerification.sendEmail({ + emailVerifyLink: `${config.appOrigin[0]}/auth/verify-email?token=${tokenResponse.token}&rid=emailverification`, + type: "EMAIL_VERIFICATION", + user: { + email: arguments_.email, + id: user.id, + }, + userContext: { + _default: { + request: { + request: request, + }, + }, + }, + }); + + return { + message: "A verification link has been sent to your email.", + status: "OK", + }; + } + + return new mercurius.ErrorWithProps(tokenResponse.status); + } + } + + const service = getUserService(config, database, dbSchema); + + const response = await service.changeEmail(user.id, arguments_.email); + + request.user = response; + + return { + message: "Email updated successfully.", + status: "OK", + }; + } else { + return new mercurius.ErrorWithProps("USER_NOT_FOUND"); + } + /*eslint-disable-next-line @typescript-eslint/no-explicit-any */ + } catch (error: any) { + app.log.error(error); + + if (error.message === "EMAIL_ALREADY_EXISTS_ERROR") { + return new mercurius.ErrorWithProps(error.message); + } + + return new mercurius.ErrorWithProps( + "Oops, Something went wrong", + {}, + 500, + ); + } + }, + changePassword: async ( + parent: unknown, + arguments_: { + newPassword: string; + oldPassword: string; + }, + context: MercuriusContext, + ) => { + const { app, config, database, dbSchema, reply, user } = context; + + const service = getUserService(config, database, dbSchema); + + if (!user) { + return new mercurius.ErrorWithProps("unauthorized", {}, 401); + } + + try { + const response = await service.changePassword( + user.id, + arguments_.oldPassword, + arguments_.newPassword, + ); + + if (response.status === "OK") { + await createNewSession(reply.request, reply, user.id); + } + + return response; + } catch (error) { + // FIXME [OP 28 SEP 2022] + app.log.error(error); + + const mercuriusError = new mercurius.ErrorWithProps( + "Oops, Something went wrong", + ); + mercuriusError.statusCode = 500; + + return mercuriusError; + } + }, deleteMe: async ( parent: unknown, arguments_: { @@ -223,12 +380,9 @@ const Mutation = { return { status: "OK" }; }, - changePassword: async ( + removePhoto: async ( parent: unknown, - arguments_: { - oldPassword: string; - newPassword: string; - }, + arguments_: undefined, context: MercuriusContext, ) => { const { app, config, database, dbSchema, reply, user } = context; @@ -240,19 +394,33 @@ const Mutation = { } try { - const response = await service.changePassword( - user.id, - arguments_.oldPassword, - arguments_.newPassword, - ); + // eslint-disable-next-line unicorn/no-null + const updatedUser = await service.update(user.id, { photoId: null }); - if (response.status === "OK") { - await createNewSession(reply.request, reply, user.id); + if (user.photoId) { + await service.fileService.delete(user.photoId); } - return response; + const request = reply.request; + + request.user = updatedUser; + + if (request.config.user.features?.profileValidation?.enabled) { + await request.session?.fetchAndSetClaim( + new ProfileValidationClaim(), + createUserContext(undefined, request), + ); + } + + if (request.config.user.features?.signUp?.emailVerification) { + await request.session?.fetchAndSetClaim( + EmailVerificationClaim, + createUserContext(undefined, request), + ); + } + + return updatedUser; } catch (error) { - // FIXME [OP 28 SEP 2022] app.log.error(error); const mercuriusError = new mercurius.ErrorWithProps( @@ -391,173 +559,6 @@ const Mutation = { return mercuriusError; } }, - removePhoto: async ( - parent: unknown, - arguments_: undefined, - context: MercuriusContext, - ) => { - const { app, config, database, dbSchema, reply, user } = context; - - const service = getUserService(config, database, dbSchema); - - if (!user) { - return new mercurius.ErrorWithProps("unauthorized", {}, 401); - } - - try { - // eslint-disable-next-line unicorn/no-null - const updatedUser = await service.update(user.id, { photoId: null }); - - if (user.photoId) { - await service.fileService.delete(user.photoId); - } - - const request = reply.request; - - request.user = updatedUser; - - if (request.config.user.features?.profileValidation?.enabled) { - await request.session?.fetchAndSetClaim( - new ProfileValidationClaim(), - createUserContext(undefined, request), - ); - } - - if (request.config.user.features?.signUp?.emailVerification) { - await request.session?.fetchAndSetClaim( - EmailVerificationClaim, - createUserContext(undefined, request), - ); - } - - return updatedUser; - } catch (error) { - app.log.error(error); - - const mercuriusError = new mercurius.ErrorWithProps( - "Oops, Something went wrong", - ); - mercuriusError.statusCode = 500; - - return mercuriusError; - } - }, - changeEmail: async ( - parent: unknown, - arguments_: { - email: string; - }, - context: MercuriusContext, - ) => { - const { app, config, database, dbSchema, user, reply } = context; - - try { - if (user) { - if (config.user.features?.updateEmail?.enabled === false) { - return new mercurius.ErrorWithProps("EMAIL_FEATURE_DISABLED_ERROR"); - } - - const request = reply.request; - - if (config.user.features?.profileValidation?.enabled) { - await request.session?.fetchAndSetClaim( - new ProfileValidationClaim(), - createUserContext(undefined, request), - ); - } - - if (config.user.features?.signUp?.emailVerification) { - await request.session?.fetchAndSetClaim( - EmailVerificationClaim, - createUserContext(undefined, request), - ); - } - - const emailValidationResult = validateEmail(arguments_.email, config); - - if (!emailValidationResult.success) { - return new mercurius.ErrorWithProps("EMAIL_INVALID_ERROR"); - } - - if (user.email === arguments_.email) { - return new mercurius.ErrorWithProps("EMAIL_SAME_AS_CURRENT_ERROR"); - } - - if (config.user.features?.signUp?.emailVerification) { - const isVerified = await isEmailVerified(user.id, arguments_.email); - - if (!isVerified) { - const users = await getUsersByEmail(arguments_.email); - - const emailPasswordRecipeUsers = users.filter( - (user) => !user.thirdParty, - ); - - if (emailPasswordRecipeUsers.length > 0) { - return new mercurius.ErrorWithProps("EMAIL_ALREADY_EXISTS_ERROR"); - } - - const tokenResponse = - await EmailVerification.createEmailVerificationToken( - user.id, - arguments_.email, - ); - - if (tokenResponse.status === "OK") { - await EmailVerification.sendEmail({ - type: "EMAIL_VERIFICATION", - user: { - id: user.id, - email: arguments_.email, - }, - emailVerifyLink: `${config.appOrigin[0]}/auth/verify-email?token=${tokenResponse.token}&rid=emailverification`, - userContext: { - _default: { - request: { - request: request, - }, - }, - }, - }); - - return { - status: "OK", - message: "A verification link has been sent to your email.", - }; - } - - return new mercurius.ErrorWithProps(tokenResponse.status); - } - } - - const service = getUserService(config, database, dbSchema); - - const response = await service.changeEmail(user.id, arguments_.email); - - request.user = response; - - return { - status: "OK", - message: "Email updated successfully.", - }; - } else { - return new mercurius.ErrorWithProps("USER_NOT_FOUND"); - } - /*eslint-disable-next-line @typescript-eslint/no-explicit-any */ - } catch (error: any) { - app.log.error(error); - - if (error.message === "EMAIL_ALREADY_EXISTS_ERROR") { - return new mercurius.ErrorWithProps(error.message); - } - - return new mercurius.ErrorWithProps( - "Oops, Something went wrong", - {}, - 500, - ); - } - }, }; const Query = { @@ -649,9 +650,9 @@ const Query = { users: async ( parent: unknown, arguments_: { + filters?: FilterInput; limit: number; offset: number; - filters?: FilterInput; sort?: SortInput[]; }, context: MercuriusContext, diff --git a/packages/user/src/model/users/handlers/adminSignUp.ts b/packages/user/src/model/users/handlers/adminSignUp.ts index 054e795b8..93f568507 100644 --- a/packages/user/src/model/users/handlers/adminSignUp.ts +++ b/packages/user/src/model/users/handlers/adminSignUp.ts @@ -1,3 +1,5 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; + import { createNewSession } from "supertokens-node/recipe/session"; import { emailPasswordSignUp } from "supertokens-node/recipe/thirdpartyemailpassword"; import UserRoles from "supertokens-node/recipe/userroles"; @@ -6,8 +8,6 @@ import { ROLE_ADMIN, ROLE_SUPERADMIN } from "../../../constants"; import validateEmail from "../../../validator/email"; import validatePassword from "../../../validator/password"; -import type { FastifyReply, FastifyRequest } from "fastify"; - interface FieldInput { email: string; password: string; @@ -56,16 +56,16 @@ const adminSignUp = async (request: FastifyRequest, reply: FastifyReply) => { // signup const signUpResponse = await emailPasswordSignUp(email, password, { - autoVerifyEmail: true, - roles: [ - ROLE_ADMIN, - ...(superAdminUsers.status === "OK" ? [ROLE_SUPERADMIN] : []), - ], _default: { request: { request, }, }, + autoVerifyEmail: true, + roles: [ + ROLE_ADMIN, + ...(superAdminUsers.status === "OK" ? [ROLE_SUPERADMIN] : []), + ], }); if (signUpResponse.status !== "OK") { diff --git a/packages/user/src/model/users/handlers/canAdminSignUp.ts b/packages/user/src/model/users/handlers/canAdminSignUp.ts index 9380b56ab..dc9993a16 100644 --- a/packages/user/src/model/users/handlers/canAdminSignUp.ts +++ b/packages/user/src/model/users/handlers/canAdminSignUp.ts @@ -1,9 +1,9 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; + import UserRoles from "supertokens-node/recipe/userroles"; import { ROLE_ADMIN, ROLE_SUPERADMIN } from "../../../constants"; -import type { FastifyReply, FastifyRequest } from "fastify"; - const canAdminSignUp = async (request: FastifyRequest, reply: FastifyReply) => { const { server } = request; diff --git a/packages/user/src/model/users/handlers/changeEmail.ts b/packages/user/src/model/users/handlers/changeEmail.ts index 280c095c8..d3926fb11 100644 --- a/packages/user/src/model/users/handlers/changeEmail.ts +++ b/packages/user/src/model/users/handlers/changeEmail.ts @@ -1,3 +1,5 @@ +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { FastifyReply } from "fastify"; import EmailVerification, { EmailVerificationClaim, @@ -5,16 +7,15 @@ import EmailVerification, { } from "supertokens-node/recipe/emailverification"; import { getUsersByEmail } from "supertokens-node/recipe/thirdpartyemailpassword"; +import type { ChangeEmailInput } from "../../../types"; + import getUserService from "../../../lib/getUserService"; import createUserContext from "../../../supertokens/utils/createUserContext"; import ProfileValidationClaim from "../../../supertokens/utils/profileValidationClaim"; import validateEmail from "../../../validator/email"; -import type { ChangeEmailInput } from "../../../types"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const changeEmail = async (request: SessionRequest, reply: FastifyReply) => { - const { body, config, user, server, slonik, session } = request; + const { body, config, server, session, slonik, user } = request; if (!user) { throw server.httpErrors.unauthorized("Unauthorised"); @@ -53,8 +54,8 @@ const changeEmail = async (request: SessionRequest, reply: FastifyReply) => { if (user.email === email) { return reply.send({ - status: "EMAIL_SAME_AS_CURRENT_ERROR", message: "Email is same as the current one.", + status: "EMAIL_SAME_AS_CURRENT_ERROR", }); } @@ -79,12 +80,12 @@ const changeEmail = async (request: SessionRequest, reply: FastifyReply) => { if (tokenResponse.status === "OK") { await EmailVerification.sendEmail({ + emailVerifyLink: `${config.appOrigin[0]}/auth/verify-email?token=${tokenResponse.token}&rid=emailverification`, type: "EMAIL_VERIFICATION", user: { - id: user.id, email: email, + id: user.id, }, - emailVerifyLink: `${config.appOrigin[0]}/auth/verify-email?token=${tokenResponse.token}&rid=emailverification`, userContext: { _default: { request: { @@ -95,8 +96,8 @@ const changeEmail = async (request: SessionRequest, reply: FastifyReply) => { }); return reply.send({ - status: "OK", message: "A verification link has been sent to your email.", + status: "OK", }); } @@ -110,7 +111,7 @@ const changeEmail = async (request: SessionRequest, reply: FastifyReply) => { request.user = response; - return reply.send({ status: "OK", message: "Email updated successfully." }); + return reply.send({ message: "Email updated successfully.", status: "OK" }); /*eslint-disable-next-line @typescript-eslint/no-explicit-any */ } catch (error: any) { if (error.message === "EMAIL_ALREADY_EXISTS_ERROR") { diff --git a/packages/user/src/model/users/handlers/changePassword.ts b/packages/user/src/model/users/handlers/changePassword.ts index 5a923a1b3..98bff47b0 100644 --- a/packages/user/src/model/users/handlers/changePassword.ts +++ b/packages/user/src/model/users/handlers/changePassword.ts @@ -1,12 +1,13 @@ +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { createNewSession } from "supertokens-node/recipe/session"; +import type { ChangePasswordInput } from "../../../types"; + import getUserService from "../../../lib/getUserService"; import createUserContext from "../../../supertokens/utils/createUserContext"; -import type { ChangePasswordInput } from "../../../types"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const changePassword = async (request: SessionRequest, reply: FastifyReply) => { const { body, config, dbSchema, server, slonik, user } = request; diff --git a/packages/user/src/model/users/handlers/deleteMe.ts b/packages/user/src/model/users/handlers/deleteMe.ts index 56806dd5b..f6915fdfe 100644 --- a/packages/user/src/model/users/handlers/deleteMe.ts +++ b/packages/user/src/model/users/handlers/deleteMe.ts @@ -1,10 +1,10 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import getUserService from "../../../lib/getUserService"; -import type { FastifyReply, FastifyRequest } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const deleteMe = async (request: SessionRequest, reply: FastifyReply) => { const { body, config, dbSchema, server, slonik, user } = request as FastifyRequest<{ diff --git a/packages/user/src/model/users/handlers/disable.ts b/packages/user/src/model/users/handlers/disable.ts index 43d3b4b6d..910780ef7 100644 --- a/packages/user/src/model/users/handlers/disable.ts +++ b/packages/user/src/model/users/handlers/disable.ts @@ -1,8 +1,8 @@ -import getUserService from "../../../lib/getUserService"; - import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import getUserService from "../../../lib/getUserService"; + const disable = async (request: SessionRequest, reply: FastifyReply) => { const { config, dbSchema, server, slonik, user } = request; diff --git a/packages/user/src/model/users/handlers/enable.ts b/packages/user/src/model/users/handlers/enable.ts index 758632462..e3cbc4587 100644 --- a/packages/user/src/model/users/handlers/enable.ts +++ b/packages/user/src/model/users/handlers/enable.ts @@ -1,8 +1,8 @@ -import getUserService from "../../../lib/getUserService"; - import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import getUserService from "../../../lib/getUserService"; + const enable = async (request: SessionRequest, reply: FastifyReply) => { const { config, dbSchema, server, slonik, user } = request; diff --git a/packages/user/src/model/users/handlers/me.ts b/packages/user/src/model/users/handlers/me.ts index 8d9fa16af..0c131af9f 100644 --- a/packages/user/src/model/users/handlers/me.ts +++ b/packages/user/src/model/users/handlers/me.ts @@ -1,12 +1,12 @@ +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { EmailVerificationClaim } from "supertokens-node/recipe/emailverification"; import { getUserById } from "supertokens-node/recipe/thirdpartyemailpassword"; import createUserContext from "../../../supertokens/utils/createUserContext"; import ProfileValidationClaim from "../../../supertokens/utils/profileValidationClaim"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const me = async (request: SessionRequest, reply: FastifyReply) => { const { config, server, session, user } = request; diff --git a/packages/user/src/model/users/handlers/removePhoto.ts b/packages/user/src/model/users/handlers/removePhoto.ts index 4f5bf2e61..c00496aba 100644 --- a/packages/user/src/model/users/handlers/removePhoto.ts +++ b/packages/user/src/model/users/handlers/removePhoto.ts @@ -1,3 +1,6 @@ +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { EmailVerificationClaim } from "supertokens-node/recipe/emailverification"; import { getUserById } from "supertokens-node/recipe/thirdpartyemailpassword"; @@ -5,9 +8,6 @@ import getUserService from "../../../lib/getUserService"; import createUserContext from "../../../supertokens/utils/createUserContext"; import ProfileValidationClaim from "../../../supertokens/utils/profileValidationClaim"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const removePhoto = async (request: SessionRequest, reply: FastifyReply) => { const { config, dbSchema, server, slonik, user } = request; diff --git a/packages/user/src/model/users/handlers/updateMe.ts b/packages/user/src/model/users/handlers/updateMe.ts index 0254c3ca0..f1fb4f227 100644 --- a/packages/user/src/model/users/handlers/updateMe.ts +++ b/packages/user/src/model/users/handlers/updateMe.ts @@ -1,18 +1,19 @@ +import type { File } from "@prefabs.tech/fastify-s3"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import { EmailVerificationClaim } from "supertokens-node/recipe/emailverification"; import { getUserById } from "supertokens-node/recipe/thirdpartyemailpassword"; +import type { UserUpdateInput } from "../../../types"; + import { ERROR_CODES } from "../../../constants"; import getUserService from "../../../lib/getUserService"; import createUserContext from "../../../supertokens/utils/createUserContext"; import ProfileValidationClaim from "../../../supertokens/utils/profileValidationClaim"; import filterUserUpdateInput from "../filterUserUpdateInput"; -import type { UserUpdateInput } from "../../../types"; -import type { File } from "@prefabs.tech/fastify-s3"; -import type { FastifyReply, FastifyRequest } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const updateMe = async (request: SessionRequest, reply: FastifyReply) => { const { body, config, dbSchema, server, slonik, user } = request as FastifyRequest<{ diff --git a/packages/user/src/model/users/handlers/uploadPhoto.ts b/packages/user/src/model/users/handlers/uploadPhoto.ts index 07878fbbd..fbfd2384e 100644 --- a/packages/user/src/model/users/handlers/uploadPhoto.ts +++ b/packages/user/src/model/users/handlers/uploadPhoto.ts @@ -1,17 +1,18 @@ +import type { Multipart } from "@prefabs.tech/fastify-s3"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import { EmailVerificationClaim } from "supertokens-node/recipe/emailverification"; import { getUserById } from "supertokens-node/recipe/thirdpartyemailpassword"; +import type { UserUpdateInput } from "../../../types"; + import { ERROR_CODES } from "../../../constants"; import getUserService from "../../../lib/getUserService"; import createUserContext from "../../../supertokens/utils/createUserContext"; import ProfileValidationClaim from "../../../supertokens/utils/profileValidationClaim"; -import type { UserUpdateInput } from "../../../types"; -import type { Multipart } from "@prefabs.tech/fastify-s3"; -import type { FastifyReply, FastifyRequest } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const uploadPhoto = async (request: SessionRequest, reply: FastifyReply) => { const { body, config, dbSchema, server, slonik, user } = request as FastifyRequest<{ diff --git a/packages/user/src/model/users/handlers/user.ts b/packages/user/src/model/users/handlers/user.ts index d8d623844..0de2764e4 100644 --- a/packages/user/src/model/users/handlers/user.ts +++ b/packages/user/src/model/users/handlers/user.ts @@ -1,8 +1,8 @@ -import getUserService from "../../../lib/getUserService"; - import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import getUserService from "../../../lib/getUserService"; + const user = async (request: SessionRequest, reply: FastifyReply) => { const service = getUserService( request.config, diff --git a/packages/user/src/model/users/handlers/users.ts b/packages/user/src/model/users/handlers/users.ts index 782d45b54..0a28a042b 100644 --- a/packages/user/src/model/users/handlers/users.ts +++ b/packages/user/src/model/users/handlers/users.ts @@ -1,8 +1,8 @@ -import getUserService from "../../../lib/getUserService"; - import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import getUserService from "../../../lib/getUserService"; + const users = async (request: SessionRequest, reply: FastifyReply) => { const service = getUserService( request.config, @@ -10,10 +10,10 @@ const users = async (request: SessionRequest, reply: FastifyReply) => { request.dbSchema, ); - const { limit, offset, filters, sort } = request.query as { + const { filters, limit, offset, sort } = request.query as { + filters?: string; limit: number; offset?: number; - filters?: string; sort?: string; }; diff --git a/packages/user/src/model/users/schema.ts b/packages/user/src/model/users/schema.ts index 0c41f2d73..2ec22583c 100644 --- a/packages/user/src/model/users/schema.ts +++ b/packages/user/src/model/users/schema.ts @@ -1,41 +1,41 @@ const userSchema = { - type: "object", + additionalProperties: true, properties: { - id: { type: "string" }, - email: { type: "string", format: "email" }, - roles: { type: "array", items: { type: "string" } }, + deletedAt: { nullable: true, type: "number" }, disabled: { type: "boolean" }, + email: { format: "email", type: "string" }, + id: { type: "string" }, lastLoginAt: { type: "number" }, + photoId: { nullable: true, type: "number" }, + roles: { items: { type: "string" }, type: "array" }, signedUpAt: { type: "number" }, - deletedAt: { type: "number", nullable: true }, - photoId: { type: "number", nullable: true }, }, - additionalProperties: true, required: ["id", "email", "roles", "disabled", "lastLoginAt", "signedUpAt"], + type: "object", }; export const adminSignUpSchema = { - description: "Create a new admin user", - operationId: "adminSignUp", body: { - type: "object", - required: ["email", "password"], properties: { - email: { type: "string", format: "email" }, - password: { type: "string", format: "password" }, + email: { format: "email", type: "string" }, + password: { format: "password", type: "string" }, }, + required: ["email", "password"], + type: "object", }, + description: "Create a new admin user", + operationId: "adminSignUp", response: { 200: { - type: "object", properties: { status: { type: "string" }, user: userSchema, }, + type: "object", }, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 500: { $ref: "ErrorResponse#", @@ -49,10 +49,10 @@ export const canAdminSignUpSchema = { operationId: "canAdminSignUp", response: { 200: { - type: "object", properties: { signUp: { type: "boolean" }, }, + type: "object", }, 500: { $ref: "ErrorResponse#", @@ -62,30 +62,30 @@ export const canAdminSignUpSchema = { }; export const changeEmailSchema = { - description: "Change user's email address", - operationId: "changeEmail", body: { - type: "object", - required: ["email"], properties: { - email: { type: "string", format: "email" }, + email: { format: "email", type: "string" }, }, + required: ["email"], + type: "object", }, + description: "Change user's email address", + operationId: "changeEmail", response: { 200: { - type: "object", properties: { - status: { type: "string" }, message: { type: "string" }, + status: { type: "string" }, }, + type: "object", }, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", @@ -95,32 +95,32 @@ export const changeEmailSchema = { }; export const changePasswordSchema = { - description: "Change user's password", - operationId: "changePassword", body: { - type: "object", - required: ["oldPassword", "newPassword"], properties: { - oldPassword: { type: "string", format: "password" }, - newPassword: { type: "string", format: "password" }, + newPassword: { format: "password", type: "string" }, + oldPassword: { format: "password", type: "string" }, }, + required: ["oldPassword", "newPassword"], + type: "object", }, + description: "Change user's password", + operationId: "changePassword", response: { 200: { - type: "object", properties: { - statusCode: { type: "number" }, - status: { type: "string" }, message: { type: "string" }, + status: { type: "string" }, + statusCode: { type: "number" }, }, + type: "object", }, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", @@ -130,26 +130,26 @@ export const changePasswordSchema = { }; export const deleteMeSchema = { - description: "Delete the current user's account", - operationId: "deleteMe", body: { - type: "object", - required: ["password"], properties: { - password: { type: "string", format: "password" }, + password: { format: "password", type: "string" }, }, + required: ["password"], + type: "object", }, + description: "Delete the current user's account", + operationId: "deleteMe", response: { 200: { description: "User deleted successfully", - type: "object", properties: { status: { type: "string" }, }, + type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", @@ -159,19 +159,19 @@ export const deleteMeSchema = { }; export const uploadPhotoSchema = { - description: "Upload a photo for the current user", - consumes: ["multipart/form-data"], body: { - type: "object", properties: { photo: { isFile: true }, }, + type: "object", }, + consumes: ["multipart/form-data"], + description: "Upload a photo for the current user", response: { 200: userSchema, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", @@ -186,8 +186,8 @@ export const removePhotoSchema = { response: { 200: userSchema, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", @@ -200,30 +200,30 @@ export const disableUserSchema = { description: "Disable a user account", operationId: "disableUser", params: { - type: "object", - required: ["id"], properties: { id: { type: "string" }, }, + required: ["id"], + type: "object", }, response: { 200: { - type: "object", properties: { status: { type: "string" }, }, + type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 404: { - description: "User not found", $ref: "ErrorResponse#", + description: "User not found", }, 500: { $ref: "ErrorResponse#", @@ -236,30 +236,30 @@ export const enableUserSchema = { description: "Enable a user account", operationId: "enableUser", params: { - type: "object", - required: ["id"], properties: { id: { type: "string" }, }, + required: ["id"], + type: "object", }, response: { 200: { - type: "object", properties: { status: { type: "string" }, }, + type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 404: { - description: "User not found", $ref: "ErrorResponse#", + description: "User not found", }, 500: { $ref: "ErrorResponse#", @@ -274,8 +274,8 @@ export const getMeSchema = { response: { 200: userSchema, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", @@ -288,25 +288,25 @@ export const getUserSchema = { description: "Get a user by ID", operationId: "getUser", params: { - type: "object", - required: ["id"], properties: { id: { type: "string" }, }, + required: ["id"], + type: "object", }, response: { 200: userSchema, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 404: { - description: "User not found", $ref: "ErrorResponse#", + description: "User not found", }, 500: { $ref: "ErrorResponse#", @@ -320,34 +320,34 @@ export const getUsersSchema = { "Get a paginated list of users with optional filtering and sorting", operationId: "getUsers", querystring: { - type: "object", properties: { + filters: { type: "string" }, limit: { type: "number" }, offset: { type: "number" }, - filters: { type: "string" }, sort: { type: "string" }, }, + type: "object", }, response: { 200: { - type: "object", - required: ["totalCount", "filteredCount", "data"], properties: { - totalCount: { type: "integer" }, - filteredCount: { type: "integer" }, data: { - type: "array", items: userSchema, + type: "array", }, + filteredCount: { type: "integer" }, + totalCount: { type: "integer" }, }, + required: ["totalCount", "filteredCount", "data"], + type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 500: { $ref: "ErrorResponse#", @@ -357,17 +357,17 @@ export const getUsersSchema = { }; export const updateMeSchema = { - description: "Update current user's profile", - operationId: "updateMe", body: { - type: "object", additionalProperties: true, + type: "object", }, + description: "Update current user's profile", + operationId: "updateMe", response: { 200: userSchema, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", diff --git a/packages/user/src/model/users/service.ts b/packages/user/src/model/users/service.ts index a20ff081d..d2ce8765b 100644 --- a/packages/user/src/model/users/service.ts +++ b/packages/user/src/model/users/service.ts @@ -4,30 +4,54 @@ import { BaseService } from "@prefabs.tech/fastify-slonik"; import Session from "supertokens-node/recipe/session"; import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; -import UserSqlFactory from "./sqlFactory"; +import type { User, UserCreateInput, UserUpdateInput } from "../../types"; + import { DEFAULT_USER_PHOTO_MAX_SIZE_IN_MB, ERROR_CODES, } from "../../constants"; import validatePassword from "../../validator/password"; - -import type { User, UserCreateInput, UserUpdateInput } from "../../types"; +import UserSqlFactory from "./sqlFactory"; class UserService extends BaseService { - protected photoPath = "photo"; - protected photoFilename = "photo"; + get bucket(): string | undefined { + return this.config.user.s3?.bucket; + } + get factory(): UserSqlFactory { + return super.factory as UserSqlFactory; + } + + get fileService() { + if (!this._fileService) { + this._fileService = new FileService( + this.config, + this.database, + this.schema, + ); + } + + return this._fileService; + } + get sqlFactoryClass() { + return UserSqlFactory; + } protected _fileService: FileService | undefined; + protected _supportedMimeTypes: string[] = [ "image/jpeg", "image/png", "image/webp", ]; + protected photoFilename = "photo"; + + protected photoPath = "photo"; + async changeEmail(id: string, email: string) { const response = await ThirdPartyEmailPassword.updateEmailOrPassword({ - userId: id, email: email, + userId: id, }); if (response.status !== "OK") { @@ -52,8 +76,8 @@ class UserService extends BaseService { if (!passwordValidation.success) { return { - status: "FIELD_ERROR", message: passwordValidation.message, + status: "FIELD_ERROR", }; } @@ -70,8 +94,8 @@ class UserService extends BaseService { if (isPasswordValid.status === "OK") { const result = await ThirdPartyEmailPassword.updateEmailOrPassword({ - userId, password: newPassword, + userId, }); if (result) { @@ -88,8 +112,8 @@ class UserService extends BaseService { } } else { return { - status: "INVALID_PASSWORD", message: "Invalid password", + status: "INVALID_PASSWORD", }; } } else { @@ -97,12 +121,28 @@ class UserService extends BaseService { } } else { return { - status: "FIELD_ERROR", message: "Password cannot be empty", + status: "FIELD_ERROR", }; } } + async deleteFile(fileId: number): Promise { + if (!this.bucket) { + console.warn( + "S3 bucket for user model is not configured. Skipping file delete.", + ); + + return undefined; + } + + const result = await this.fileService.deleteFile(fileId, { + bucket: this.bucket, + }); + + return result; + } + async deleteMe(userId: string, password: string) { const user = await ThirdPartyEmailPassword.getUserById(userId); @@ -127,22 +167,6 @@ class UserService extends BaseService { } } - async deleteFile(fileId: number): Promise { - if (!this.bucket) { - console.warn( - "S3 bucket for user model is not configured. Skipping file delete.", - ); - - return undefined; - } - - const result = await this.fileService.deleteFile(fileId, { - bucket: this.bucket, - }); - - return result; - } - async uploadPhoto( photo: Multipart, userId: string, @@ -155,36 +179,6 @@ class UserService extends BaseService { return this.upload(photo, path, filename, uploadedById, uploadedAt); } - get bucket(): string | undefined { - return this.config.user.s3?.bucket; - } - - get factory(): UserSqlFactory { - return super.factory as UserSqlFactory; - } - - get fileService() { - if (!this._fileService) { - this._fileService = new FileService( - this.config, - this.database, - this.schema, - ); - } - - return this._fileService; - } - - get sqlFactoryClass() { - return UserSqlFactory; - } - - protected async postDelete(result: User): Promise { - await Session.revokeAllSessionsForUser(result.id); - - return result; - } - protected getPhotoPath(userId: string): string { return `${userId}/${this.photoPath}`; } @@ -204,6 +198,12 @@ class UserService extends BaseService { return user; } + protected async postDelete(result: User): Promise { + await Session.revokeAllSessionsForUser(result.id); + + return result; + } + protected async postFindById(result: User): Promise { return await this.getUserWithPhoto(result); } @@ -258,9 +258,9 @@ class UserService extends BaseService { file: { fileContent: data, fileFields: { - uploadedById: uploadedById, - uploadedAt: uploadedAt || Date.now(), bucket: this.bucket, + uploadedAt: uploadedAt || Date.now(), + uploadedById: uploadedById, }, }, options: { diff --git a/packages/user/src/model/users/sql.ts b/packages/user/src/model/users/sql.ts index c5ad5ad8b..500d71633 100644 --- a/packages/user/src/model/users/sql.ts +++ b/packages/user/src/model/users/sql.ts @@ -1,11 +1,11 @@ +import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; +import type { FragmentSqlToken, IdentifierSqlToken } from "slonik"; + import humps from "humps"; import { sql } from "slonik"; import { applyFiltersToQuery } from "./dbFilters"; -import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; -import type { FragmentSqlToken, IdentifierSqlToken } from "slonik"; - const createRoleSortFragment = ( identifier: IdentifierSqlToken, sort?: SortInput[], diff --git a/packages/user/src/model/users/sqlFactory.ts b/packages/user/src/model/users/sqlFactory.ts index 4dedca333..45a7c215d 100644 --- a/packages/user/src/model/users/sqlFactory.ts +++ b/packages/user/src/model/users/sqlFactory.ts @@ -1,21 +1,25 @@ +import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; + import { DefaultSqlFactory } from "@prefabs.tech/fastify-slonik"; import humps from "humps"; import { FragmentSqlToken, QuerySqlToken, sql } from "slonik"; import { z } from "zod"; +import { TABLE_USERS } from "../../constants"; +import { ChangeEmailInput, UserUpdateInput } from "../../types"; import { createRoleSortFragment, createUserFilterFragment, createUserSortFragment, } from "./sql"; -import { TABLE_USERS } from "../../constants"; -import { ChangeEmailInput, UserUpdateInput } from "../../types"; - -import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; class UserSqlFactory extends DefaultSqlFactory { static readonly TABLE = TABLE_USERS; + get table() { + return this.config.user?.tables?.users?.name || super.table; + } + protected _softDeleteEnabled: boolean = true; getCountSql(filters?: FilterInput): QuerySqlToken { @@ -79,7 +83,7 @@ class UserSqlFactory extends DefaultSqlFactory { getUpdateSql( id: number | string, - data: UserUpdateInput | ChangeEmailInput, + data: ChangeEmailInput | UserUpdateInput, ): QuerySqlToken { const columns = []; @@ -109,10 +113,6 @@ class UserSqlFactory extends DefaultSqlFactory { `; } - get table() { - return this.config.user?.tables?.users?.name || super.table; - } - protected getFilterFragment(filters?: FilterInput): FragmentSqlToken { return createUserFilterFragment(filters, this.tableIdentifier); } diff --git a/packages/user/src/plugin.ts b/packages/user/src/plugin.ts index ca3623d27..b61d94b76 100644 --- a/packages/user/src/plugin.ts +++ b/packages/user/src/plugin.ts @@ -1,3 +1,6 @@ +import type { GraphqlEnabledPlugin } from "@prefabs.tech/fastify-graphql"; +import type { FastifyPluginAsync } from "fastify"; + import FastifyPlugin from "fastify-plugin"; import seedRoles from "./lib/seedRoles"; @@ -11,9 +14,6 @@ import usersRoutes from "./model/users/controller"; import supertokensPlugin from "./supertokens"; import userContext from "./userContext"; -import type { GraphqlEnabledPlugin } from "@prefabs.tech/fastify-graphql"; -import type { FastifyPluginAsync } from "fastify"; - const userPlugin: FastifyPluginAsync = async (fastify) => { const { graphql, user } = fastify.config; diff --git a/packages/user/src/schemas/password.ts b/packages/user/src/schemas/password.ts index a609a4660..0adf6b776 100644 --- a/packages/user/src/schemas/password.ts +++ b/packages/user/src/schemas/password.ts @@ -6,16 +6,16 @@ import type { PasswordErrorMessages, StrongPasswordOptions } from "../types"; const defaultOptions = { minLength: 8, minLowercase: 0, - minUppercase: 0, minNumbers: 0, minSymbols: 0, - returnScore: false, - pointsPerUnique: 1, - pointsPerRepeat: 0.5, + minUppercase: 0, pointsForContainingLower: 10, - pointsForContainingUpper: 10, pointsForContainingNumber: 10, pointsForContainingSymbol: 10, + pointsForContainingUpper: 10, + pointsPerRepeat: 0.5, + pointsPerUnique: 1, + returnScore: false, }; const schema = ( @@ -35,9 +35,9 @@ const schema = ( (value): boolean => { return validator.isStrongPassword( value, - _options as StrongPasswordOptions & { + _options as { returnScore: false | undefined; - }, + } & StrongPasswordOptions, ); }, { diff --git a/packages/user/src/supertokens/init.ts b/packages/user/src/supertokens/init.ts index 15a34cf4f..05fb5b751 100644 --- a/packages/user/src/supertokens/init.ts +++ b/packages/user/src/supertokens/init.ts @@ -1,9 +1,9 @@ +import type { FastifyInstance } from "fastify"; + import supertokens from "supertokens-node"; import getRecipeList from "./recipes"; -import type { FastifyInstance } from "fastify"; - const init = (fastify: FastifyInstance) => { const { config } = fastify; diff --git a/packages/user/src/supertokens/plugin.ts b/packages/user/src/supertokens/plugin.ts index 2458a059b..291068e05 100644 --- a/packages/user/src/supertokens/plugin.ts +++ b/packages/user/src/supertokens/plugin.ts @@ -1,3 +1,5 @@ +import type { FastifyInstance } from "fastify"; + import FastifyPlugin from "fastify-plugin"; import { plugin as supertokensPlugin } from "supertokens-node/framework/fastify"; import { verifySession } from "supertokens-node/recipe/session/framework/fastify"; @@ -5,8 +7,6 @@ import { verifySession } from "supertokens-node/recipe/session/framework/fastify import { errorHandler } from "./errorHandler"; import init from "./init"; -import type { FastifyInstance } from "fastify"; - const plugin = async (fastify: FastifyInstance) => { const { config, log } = fastify; diff --git a/packages/user/src/supertokens/recipes/config/email-verification/sendEmailVerificationEmail.ts b/packages/user/src/supertokens/recipes/config/email-verification/sendEmailVerificationEmail.ts index b32b3424b..b500b9d0e 100644 --- a/packages/user/src/supertokens/recipes/config/email-verification/sendEmailVerificationEmail.ts +++ b/packages/user/src/supertokens/recipes/config/email-verification/sendEmailVerificationEmail.ts @@ -1,13 +1,13 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { EmailDeliveryInterface } from "supertokens-node/lib/build/ingredients/emaildelivery/types"; +import type { TypeEmailVerificationEmailDeliveryInput } from "supertokens-node/recipe/emailverification/types"; + import emailVerification from "supertokens-node/recipe/emailverification"; import { EMAIL_VERIFICATION_PATH } from "../../../../constants"; import getOrigin from "../../../../lib/getOrigin"; import sendEmail from "../../../../lib/sendEmail"; -import type { FastifyInstance, FastifyRequest } from "fastify"; -import type { EmailDeliveryInterface } from "supertokens-node/lib/build/ingredients/emaildelivery/types"; -import type { TypeEmailVerificationEmailDeliveryInput } from "supertokens-node/recipe/emailverification/types"; - const sendEmailVerificationEmail = ( originalImplementation: EmailDeliveryInterface, fastify: FastifyInstance, @@ -41,14 +41,14 @@ const sendEmailVerificationEmail = ( subject: fastify.config.user.emailOverrides?.emailVerification?.subject || "Email verification", - templateName: - fastify.config.user.emailOverrides?.emailVerification?.templateName || - "email-verification", - to: input.user.email, templateData: { emailVerifyLink, user: input.user, }, + templateName: + fastify.config.user.emailOverrides?.emailVerification?.templateName || + "email-verification", + to: input.user.email, }); }; }; diff --git a/packages/user/src/supertokens/recipes/config/emailVerificationRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/emailVerificationRecipeConfig.ts index 9f686c4bf..e2cee9212 100644 --- a/packages/user/src/supertokens/recipes/config/emailVerificationRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/emailVerificationRecipeConfig.ts @@ -1,18 +1,19 @@ -import sendEmailVerificationEmail from "./email-verification/sendEmailVerificationEmail"; -import { EMAIL_VERIFICATION_MODE } from "../../../constants"; -import getUserService from "../../../lib/getUserService"; - -import type { - SendEmailWrapper, - EmailVerificationRecipe, -} from "../../types/emailVerificationRecipe"; import type { FastifyInstance } from "fastify"; import type { APIInterface, - RecipeInterface, TypeInput as EmailVerificationRecipeConfig, + RecipeInterface, } from "supertokens-node/recipe/emailverification/types"; +import type { + EmailVerificationRecipe, + SendEmailWrapper, +} from "../../types/emailVerificationRecipe"; + +import { EMAIL_VERIFICATION_MODE } from "../../../constants"; +import getUserService from "../../../lib/getUserService"; +import sendEmailVerificationEmail from "./email-verification/sendEmailVerificationEmail"; + const getEmailVerificationRecipeConfig = ( fastify: FastifyInstance, ): EmailVerificationRecipeConfig => { @@ -25,7 +26,6 @@ const getEmailVerificationRecipeConfig = ( } return { - mode: emailVerification?.mode || EMAIL_VERIFICATION_MODE, emailDelivery: { override: (originalImplementation) => { let sendEmailConfig: SendEmailWrapper | undefined; @@ -42,6 +42,7 @@ const getEmailVerificationRecipeConfig = ( }; }, }, + mode: emailVerification?.mode || EMAIL_VERIFICATION_MODE, override: { apis: (originalImplementation) => { const apiInterface: Partial = {}; diff --git a/packages/user/src/supertokens/recipes/config/session/createNewSession.ts b/packages/user/src/supertokens/recipes/config/session/createNewSession.ts index 95c9ae328..a3ebbabc1 100644 --- a/packages/user/src/supertokens/recipes/config/session/createNewSession.ts +++ b/packages/user/src/supertokens/recipes/config/session/createNewSession.ts @@ -1,11 +1,11 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { RecipeInterface } from "supertokens-node/recipe/session/types"; + import { getRequestFromUserContext } from "supertokens-node"; import getUserService from "../../../../lib/getUserService"; import ProfileValidationClaim from "../../../utils/profileValidationClaim"; -import type { FastifyInstance, FastifyRequest } from "fastify"; -import type { RecipeInterface } from "supertokens-node/recipe/session/types"; - const createNewSession = ( originalImplementation: RecipeInterface, diff --git a/packages/user/src/supertokens/recipes/config/session/getGlobalClaimValidators.ts b/packages/user/src/supertokens/recipes/config/session/getGlobalClaimValidators.ts index be3666a5a..a8fc5893f 100644 --- a/packages/user/src/supertokens/recipes/config/session/getGlobalClaimValidators.ts +++ b/packages/user/src/supertokens/recipes/config/session/getGlobalClaimValidators.ts @@ -1,10 +1,10 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { RecipeInterface } from "supertokens-node/recipe/session/types"; + import { getRequestFromUserContext } from "supertokens-node"; import ProfileValidationClaim from "../../../utils/profileValidationClaim"; -import type { FastifyInstance, FastifyRequest } from "fastify"; -import type { RecipeInterface } from "supertokens-node/recipe/session/types"; - const getGlobalClaimValidators = ( originalImplementation: RecipeInterface, // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/packages/user/src/supertokens/recipes/config/session/getSession.ts b/packages/user/src/supertokens/recipes/config/session/getSession.ts index e5b1def20..874a0869b 100644 --- a/packages/user/src/supertokens/recipes/config/session/getSession.ts +++ b/packages/user/src/supertokens/recipes/config/session/getSession.ts @@ -1,8 +1,8 @@ -import getUserService from "../../../../lib/getUserService"; - import type { FastifyInstance, FastifyRequest } from "fastify"; import type { RecipeInterface } from "supertokens-node/recipe/session/types"; +import getUserService from "../../../../lib/getUserService"; + const getSession = ( originalImplementation: RecipeInterface, // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/packages/user/src/supertokens/recipes/config/sessionRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/sessionRecipeConfig.ts index 0336391d8..be9245a9c 100644 --- a/packages/user/src/supertokens/recipes/config/sessionRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/sessionRecipeConfig.ts @@ -1,9 +1,3 @@ -import createNewSession from "./session/createNewSession"; -import getGlobalClaimValidators from "./session/getGlobalClaimValidators"; -import getSession from "./session/getSession"; -import verifySession from "./session/verifySession"; - -import type { SessionRecipe } from "../../types/sessionRecipe"; import type { FastifyInstance } from "fastify"; import type { APIInterface, @@ -11,6 +5,13 @@ import type { TypeInput as SessionRecipeConfig, } from "supertokens-node/recipe/session/types"; +import type { SessionRecipe } from "../../types/sessionRecipe"; + +import createNewSession from "./session/createNewSession"; +import getGlobalClaimValidators from "./session/getGlobalClaimValidators"; +import getSession from "./session/getSession"; +import verifySession from "./session/verifySession"; + const getSessionRecipeConfig = ( fastify: FastifyInstance, ): SessionRecipeConfig => { @@ -80,11 +81,11 @@ const getSessionRecipeConfig = ( return { ...originalImplementation, createNewSession: createNewSession(originalImplementation, fastify), - getSession: getSession(originalImplementation, fastify), getGlobalClaimValidators: getGlobalClaimValidators( originalImplementation, fastify, ), + getSession: getSession(originalImplementation, fastify), ...recipeInterface, }; }, diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignIn.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignIn.ts index ca322e50e..68d5eb6c3 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignIn.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignIn.ts @@ -1,10 +1,11 @@ -import { formatDate } from "@prefabs.tech/fastify-slonik"; +import type { FastifyInstance } from "fastify"; +import type { RecipeInterface } from "supertokens-node/recipe/thirdpartyemailpassword/types"; -import getUserService from "../../../../lib/getUserService"; +import { formatDate } from "@prefabs.tech/fastify-slonik"; import type { AuthUser } from "../../../../types"; -import type { FastifyInstance } from "fastify"; -import type { RecipeInterface } from "supertokens-node/recipe/thirdpartyemailpassword/types"; + +import getUserService from "../../../../lib/getUserService"; const emailPasswordSignIn = ( originalImplementation: RecipeInterface, diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUp.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUp.ts index acddb2843..77f33cfc1 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUp.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUp.ts @@ -1,17 +1,18 @@ +import type { FastifyInstance } from "fastify"; +import type { RecipeInterface } from "supertokens-node/recipe/thirdpartyemailpassword"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import { deleteUser } from "supertokens-node"; import EmailVerification from "supertokens-node/recipe/emailverification"; import UserRoles from "supertokens-node/recipe/userroles"; +import type { User } from "../../../../types"; + import getUserService from "../../../../lib/getUserService"; import sendEmail from "../../../../lib/sendEmail"; import verifyEmail from "../../../../lib/verifyEmail"; import areRolesExist from "../../../utils/areRolesExist"; -import type { User } from "../../../../types"; -import type { FastifyInstance } from "fastify"; -import type { RecipeInterface } from "supertokens-node/recipe/thirdpartyemailpassword"; - const emailPasswordSignUp = ( originalImplementation: RecipeInterface, fastify: FastifyInstance, @@ -34,12 +35,12 @@ const emailPasswordSignUp = ( if (originalResponse.status === "OK") { const userService = getUserService(config, slonik); - let user: User | null | undefined; + let user: null | undefined | User; try { user = await userService.create({ - id: originalResponse.user.id, email: originalResponse.user.email, + id: originalResponse.user.id, }); if (!user) { @@ -85,9 +86,9 @@ const emailPasswordSignUp = ( // [DU 2023-SEP-4] We need to provide all the arguments. // emailVerifyLink is same as what would supertokens create. await EmailVerification.sendEmail({ + emailVerifyLink: `${config.appOrigin[0]}/auth/verify-email?token=${tokenResponse.token}&rid=emailverification`, type: "EMAIL_VERIFICATION", user: originalResponse.user, - emailVerifyLink: `${config.appOrigin[0]}/auth/verify-email?token=${tokenResponse.token}&rid=emailverification`, userContext: input.userContext, }); } diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUpPost.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUpPost.ts index 819b16d36..94aeb0559 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUpPost.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUpPost.ts @@ -1,8 +1,8 @@ -import { ROLE_USER } from "../../../../constants"; - import type { FastifyInstance } from "fastify"; import type { APIInterface } from "supertokens-node/recipe/thirdpartyemailpassword/types"; +import { ROLE_USER } from "../../../../constants"; + const emailPasswordSignUpPOST = ( originalImplementation: APIInterface, fastify: FastifyInstance, @@ -23,9 +23,9 @@ const emailPasswordSignUpPOST = ( if (originalResponse.status === "OK") { return { + session: originalResponse.session, status: "OK", user: originalResponse.user, - session: originalResponse.session, }; } diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/getFormFields.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/getFormFields.ts index f95fac37a..68a8daf0a 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/getFormFields.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/getFormFields.ts @@ -1,9 +1,9 @@ -import validateEmail from "../../../../validator/email"; -import validatePassword from "../../../../validator/password"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { TypeInputFormField } from "supertokens-node/lib/build/recipe/emailpassword/types"; +import validateEmail from "../../../../validator/email"; +import validatePassword from "../../../../validator/password"; + const getDefaultFormFields = (config: ApiConfig): TypeInputFormField[] => { return [ { diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/resetPasswordUsingToken.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/resetPasswordUsingToken.ts index 8e09cf89c..91ea0ce11 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/resetPasswordUsingToken.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/resetPasswordUsingToken.ts @@ -1,10 +1,10 @@ +import type { FastifyInstance } from "fastify"; +import type { RecipeInterface } from "supertokens-node/recipe/thirdpartyemailpassword/types"; + import { getUserById } from "supertokens-node/recipe/thirdpartyemailpassword"; import sendEmail from "../../../../lib/sendEmail"; -import type { FastifyInstance } from "fastify"; -import type { RecipeInterface } from "supertokens-node/recipe/thirdpartyemailpassword/types"; - const resetPasswordUsingToken = ( originalImplementation: RecipeInterface, fastify: FastifyInstance, @@ -22,13 +22,13 @@ const resetPasswordUsingToken = ( subject: fastify.config.user.emailOverrides?.resetPasswordNotification ?.subject || "Reset password notification", + templateData: { + emailId: user.email, + }, templateName: fastify.config.user.emailOverrides?.resetPasswordNotification ?.templateName || "reset-password-notification", to: user.email, - templateData: { - emailId: user.email, - }, }); } } diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/sendPasswordResetEmail.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/sendPasswordResetEmail.ts index aba2778a6..892c6506f 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/sendPasswordResetEmail.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/sendPasswordResetEmail.ts @@ -1,14 +1,14 @@ +import type { AppConfig } from "@prefabs.tech/fastify-config"; +import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { EmailDeliveryInterface } from "supertokens-node/lib/build/ingredients/emaildelivery/types"; +import type { TypeEmailPasswordPasswordResetEmailDeliveryInput } from "supertokens-node/lib/build/recipe/emailpassword/types"; + import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; import { RESET_PASSWORD_PATH } from "../../../../constants"; import getOrigin from "../../../../lib/getOrigin"; import sendEmail from "../../../../lib/sendEmail"; -import type { AppConfig } from "@prefabs.tech/fastify-config"; -import type { FastifyInstance, FastifyRequest } from "fastify"; -import type { EmailDeliveryInterface } from "supertokens-node/lib/build/ingredients/emaildelivery/types"; -import type { TypeEmailPasswordPasswordResetEmailDeliveryInput } from "supertokens-node/lib/build/recipe/emailpassword/types"; - const sendPasswordResetEmail = ( originalImplementation: EmailDeliveryInterface, fastify: FastifyInstance, @@ -48,14 +48,14 @@ const sendPasswordResetEmail = ( subject: fastify.config.user.emailOverrides?.resetPassword?.subject || "Reset password", - templateName: - fastify.config.user.emailOverrides?.resetPassword?.templateName || - "reset-password", - to: input.user.email, templateData: { passwordResetLink, user: input.user, }, + templateName: + fastify.config.user.emailOverrides?.resetPassword?.templateName || + "reset-password", + to: input.user.email, }); }; }; diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/thirdPartySignInUp.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/thirdPartySignInUp.ts index 207f8dcb7..55bd2836c 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/thirdPartySignInUp.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/thirdPartySignInUp.ts @@ -1,16 +1,17 @@ +import type { FastifyInstance } from "fastify"; +import type { RecipeInterface } from "supertokens-node/recipe/thirdpartyemailpassword"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import { formatDate } from "@prefabs.tech/fastify-slonik"; import { deleteUser } from "supertokens-node"; import { getUserByThirdPartyInfo } from "supertokens-node/recipe/thirdpartyemailpassword"; import UserRoles from "supertokens-node/recipe/userroles"; +import type { User } from "../../../../types"; + import getUserService from "../../../../lib/getUserService"; import areRolesExist from "../../../utils/areRolesExist"; -import type { User } from "../../../../types"; -import type { FastifyInstance } from "fastify"; -import type { RecipeInterface } from "supertokens-node/recipe/thirdpartyemailpassword"; - const thirdPartySignInUp = ( originalImplementation: RecipeInterface, fastify: FastifyInstance, @@ -60,12 +61,12 @@ const thirdPartySignInUp = ( } } - let user: User | null | undefined; + let user: null | undefined | User; try { user = await userService.create({ - id: originalResponse.user.id, email: originalResponse.user.email, + id: originalResponse.user.id, }); if (!user) { diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/thirdPartySignInUpPost.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/thirdPartySignInUpPost.ts index b92e3cc94..35539282c 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/thirdPartySignInUpPost.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/thirdPartySignInUpPost.ts @@ -1,9 +1,9 @@ -import { ROLE_USER } from "../../../../constants"; -import getUserService from "../../../../lib/getUserService"; - import type { FastifyInstance } from "fastify"; import type { APIInterface } from "supertokens-node/recipe/thirdpartyemailpassword/types"; +import { ROLE_USER } from "../../../../constants"; +import getUserService from "../../../../lib/getUserService"; + const thirdPartySignInUpPOST = ( originalImplementation: APIInterface, fastify: FastifyInstance, @@ -35,8 +35,8 @@ const thirdPartySignInUpPOST = ( ); return { - status: "GENERAL_ERROR", message: "Something went wrong", + status: "GENERAL_ERROR", }; } diff --git a/packages/user/src/supertokens/recipes/config/thirdPartyEmailPasswordRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/thirdPartyEmailPasswordRecipeConfig.ts index 69229eff1..d504068a3 100644 --- a/packages/user/src/supertokens/recipes/config/thirdPartyEmailPasswordRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/thirdPartyEmailPasswordRecipeConfig.ts @@ -1,3 +1,15 @@ +import type { FastifyInstance } from "fastify"; +import type { + APIInterface, + RecipeInterface, + TypeInput as ThirdPartyEmailPasswordRecipeConfig, +} from "supertokens-node/recipe/thirdpartyemailpassword/types"; + +import type { + SendEmailWrapper, + ThirdPartyEmailPasswordRecipe, +} from "../../types/thirdPartyEmailPasswordRecipe"; + import appleRedirectHandlerPOST from "./third-party-email-password/appleRedirectHandlerPost"; import emailPasswordSignIn from "./third-party-email-password/emailPasswordSignIn"; import emailPasswordSignUp from "./third-party-email-password/emailPasswordSignUp"; @@ -9,17 +21,6 @@ import thirdPartySignInUp from "./third-party-email-password/thirdPartySignInUp" import thirdPartySignInUpPOST from "./third-party-email-password/thirdPartySignInUpPost"; import getThirdPartyProviders from "./thirdPartyProviders"; -import type { - SendEmailWrapper, - ThirdPartyEmailPasswordRecipe, -} from "../../types/thirdPartyEmailPasswordRecipe"; -import type { FastifyInstance } from "fastify"; -import type { - APIInterface, - RecipeInterface, - TypeInput as ThirdPartyEmailPasswordRecipeConfig, -} from "supertokens-node/recipe/thirdpartyemailpassword/types"; - const getThirdPartyEmailPasswordRecipeConfig = ( fastify: FastifyInstance, ): ThirdPartyEmailPasswordRecipeConfig => { @@ -35,6 +36,22 @@ const getThirdPartyEmailPasswordRecipeConfig = ( } return { + emailDelivery: { + override: (originalImplementation) => { + let sendEmailConfig: SendEmailWrapper | undefined; + + if (thirdPartyEmailPassword?.sendEmail) { + sendEmailConfig = thirdPartyEmailPassword.sendEmail; + } + + return { + ...originalImplementation, + sendEmail: sendEmailConfig + ? sendEmailConfig(originalImplementation, fastify) + : sendPasswordResetEmail(originalImplementation, fastify), + }; + }, + }, override: { apis: (originalImplementation) => { const apiInterface: Partial = {}; @@ -59,15 +76,15 @@ const getThirdPartyEmailPasswordRecipeConfig = ( return { ...originalImplementation, - emailPasswordSignUpPOST: emailPasswordSignUpPOST( + appleRedirectHandlerPOST: appleRedirectHandlerPOST( originalImplementation, fastify, ), - thirdPartySignInUpPOST: thirdPartySignInUpPOST( + emailPasswordSignUpPOST: emailPasswordSignUpPOST( originalImplementation, fastify, ), - appleRedirectHandlerPOST: appleRedirectHandlerPOST( + thirdPartySignInUpPOST: thirdPartySignInUpPOST( originalImplementation, fastify, ), @@ -117,26 +134,10 @@ const getThirdPartyEmailPasswordRecipeConfig = ( }; }, }, + providers: getThirdPartyProviders(config), signUpFeature: { formFields: getFormFields(config), }, - emailDelivery: { - override: (originalImplementation) => { - let sendEmailConfig: SendEmailWrapper | undefined; - - if (thirdPartyEmailPassword?.sendEmail) { - sendEmailConfig = thirdPartyEmailPassword.sendEmail; - } - - return { - ...originalImplementation, - sendEmail: sendEmailConfig - ? sendEmailConfig(originalImplementation, fastify) - : sendPasswordResetEmail(originalImplementation, fastify), - }; - }, - }, - providers: getThirdPartyProviders(config), }; }; diff --git a/packages/user/src/supertokens/recipes/config/thirdPartyProviders.ts b/packages/user/src/supertokens/recipes/config/thirdPartyProviders.ts index d0b5edac6..609c9c2a2 100644 --- a/packages/user/src/supertokens/recipes/config/thirdPartyProviders.ts +++ b/packages/user/src/supertokens/recipes/config/thirdPartyProviders.ts @@ -1,18 +1,18 @@ -import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { TypeProvider } from "supertokens-node/recipe/thirdpartyemailpassword"; +import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; + const getThirdPartyProviders = (config: ApiConfig) => { const { Apple, Facebook, Github, Google } = ThirdPartyEmailPassword; const providersConfig = config.user.supertokens.providers; const providers: TypeProvider[] = []; const providerFunctions = [ - { name: "google", initProvider: Google }, - { name: "github", initProvider: Github }, - { name: "facebook", initProvider: Facebook }, - { name: "apple", initProvider: Apple }, + { initProvider: Google, name: "google" }, + { initProvider: Github, name: "github" }, + { initProvider: Facebook, name: "facebook" }, + { initProvider: Apple, name: "apple" }, ]; for (const provider of providerFunctions) { diff --git a/packages/user/src/supertokens/recipes/index.ts b/packages/user/src/supertokens/recipes/index.ts index 46d702268..0c589bc40 100644 --- a/packages/user/src/supertokens/recipes/index.ts +++ b/packages/user/src/supertokens/recipes/index.ts @@ -1,11 +1,11 @@ +import type { FastifyInstance } from "fastify"; +import type { RecipeListFunction } from "supertokens-node/types"; + import initEmailVerificationRecipe from "./initEmailVerificationRecipe"; import initSessionRecipe from "./initSessionRecipe"; import initThirdPartyEmailPassword from "./initThirdPartyEmailPasswordRecipe"; import initUserRolesRecipe from "./initUserRolesRecipe"; -import type { FastifyInstance } from "fastify"; -import type { RecipeListFunction } from "supertokens-node/types"; - const getRecipeList = (fastify: FastifyInstance): RecipeListFunction[] => { const recipeList = [ initSessionRecipe(fastify), diff --git a/packages/user/src/supertokens/recipes/initEmailVerificationRecipe.ts b/packages/user/src/supertokens/recipes/initEmailVerificationRecipe.ts index 8fef110a7..2568a2bb7 100644 --- a/packages/user/src/supertokens/recipes/initEmailVerificationRecipe.ts +++ b/packages/user/src/supertokens/recipes/initEmailVerificationRecipe.ts @@ -1,9 +1,10 @@ -import EmailVerification from "supertokens-node/recipe/emailverification"; +import type { FastifyInstance } from "fastify"; -import getEmailVerificationRecipeConfig from "./config/emailVerificationRecipeConfig"; +import EmailVerification from "supertokens-node/recipe/emailverification"; import type { SupertokensRecipes } from "../types"; -import type { FastifyInstance } from "fastify"; + +import getEmailVerificationRecipeConfig from "./config/emailVerificationRecipeConfig"; const init = (fastify: FastifyInstance) => { const emailVerification: SupertokensRecipes["emailVerification"] = diff --git a/packages/user/src/supertokens/recipes/initSessionRecipe.ts b/packages/user/src/supertokens/recipes/initSessionRecipe.ts index 571f51439..3adf21646 100644 --- a/packages/user/src/supertokens/recipes/initSessionRecipe.ts +++ b/packages/user/src/supertokens/recipes/initSessionRecipe.ts @@ -1,9 +1,10 @@ -import Session from "supertokens-node/recipe/session"; +import type { FastifyInstance } from "fastify"; -import getSessionRecipeConfig from "./config/sessionRecipeConfig"; +import Session from "supertokens-node/recipe/session"; import type { SupertokensRecipes } from "../types"; -import type { FastifyInstance } from "fastify"; + +import getSessionRecipeConfig from "./config/sessionRecipeConfig"; const init = (fastify: FastifyInstance) => { const session: SupertokensRecipes["session"] = diff --git a/packages/user/src/supertokens/recipes/initThirdPartyEmailPasswordRecipe.ts b/packages/user/src/supertokens/recipes/initThirdPartyEmailPasswordRecipe.ts index 1434bf53c..4484d89d7 100644 --- a/packages/user/src/supertokens/recipes/initThirdPartyEmailPasswordRecipe.ts +++ b/packages/user/src/supertokens/recipes/initThirdPartyEmailPasswordRecipe.ts @@ -1,9 +1,10 @@ -import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; +import type { FastifyInstance } from "fastify"; -import getThirdPartyEmailPasswordRecipeConfig from "./config/thirdPartyEmailPasswordRecipeConfig"; +import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; import type { SupertokensRecipes } from "../types"; -import type { FastifyInstance } from "fastify"; + +import getThirdPartyEmailPasswordRecipeConfig from "./config/thirdPartyEmailPasswordRecipeConfig"; const init = (fastify: FastifyInstance) => { const thirdPartyEmailPassword: SupertokensRecipes["thirdPartyEmailPassword"] = diff --git a/packages/user/src/supertokens/recipes/initUserRolesRecipe.ts b/packages/user/src/supertokens/recipes/initUserRolesRecipe.ts index b2b1f412c..c5b651ac1 100644 --- a/packages/user/src/supertokens/recipes/initUserRolesRecipe.ts +++ b/packages/user/src/supertokens/recipes/initUserRolesRecipe.ts @@ -1,9 +1,10 @@ -import UserRoles from "supertokens-node/recipe/userroles"; +import type { FastifyInstance } from "fastify"; -import getUserRolesRecipeConfig from "./config/userRolesRecipeConfig"; +import UserRoles from "supertokens-node/recipe/userroles"; import type { SupertokensRecipes } from "../types"; -import type { FastifyInstance } from "fastify"; + +import getUserRolesRecipeConfig from "./config/userRolesRecipeConfig"; const init = (fastify: FastifyInstance) => { const recipes = fastify.config.user.supertokens.recipes as SupertokensRecipes; diff --git a/packages/user/src/supertokens/types/emailVerificationRecipe.ts b/packages/user/src/supertokens/types/emailVerificationRecipe.ts index 0785c7c69..295fce14b 100644 --- a/packages/user/src/supertokens/types/emailVerificationRecipe.ts +++ b/packages/user/src/supertokens/types/emailVerificationRecipe.ts @@ -1,13 +1,13 @@ -import EmailVerification from "supertokens-node/recipe/emailverification"; - import type { FastifyInstance } from "fastify"; import type { EmailDeliveryInterface } from "supertokens-node/lib/build/ingredients/emaildelivery/types"; import type { - TypeEmailVerificationEmailDeliveryInput, APIInterface, RecipeInterface, + TypeEmailVerificationEmailDeliveryInput, } from "supertokens-node/recipe/emailverification/types"; +import EmailVerification from "supertokens-node/recipe/emailverification"; + type APIInterfaceWrapper = { [key in keyof APIInterface]?: ( originalImplementation: APIInterface, @@ -15,6 +15,15 @@ type APIInterfaceWrapper = { ) => APIInterface[key]; }; +interface EmailVerificationRecipe { + mode?: "OPTIONAL" | "REQUIRED"; + override?: { + apis?: APIInterfaceWrapper; + functions?: RecipeInterfaceWrapper; + }; + sendEmail?: SendEmailWrapper; +} + type RecipeInterfaceWrapper = { [key in keyof RecipeInterface]?: ( originalImplementation: RecipeInterface, @@ -27,18 +36,9 @@ type SendEmailWrapper = ( fastify: FastifyInstance, ) => typeof EmailVerification.sendEmail; -interface EmailVerificationRecipe { - override?: { - apis?: APIInterfaceWrapper; - functions?: RecipeInterfaceWrapper; - }; - mode?: "REQUIRED" | "OPTIONAL"; - sendEmail?: SendEmailWrapper; -} - export type { APIInterfaceWrapper, - RecipeInterfaceWrapper, EmailVerificationRecipe, + RecipeInterfaceWrapper, SendEmailWrapper, }; diff --git a/packages/user/src/supertokens/types/index.ts b/packages/user/src/supertokens/types/index.ts index b22ad8302..a7df5c2ee 100644 --- a/packages/user/src/supertokens/types/index.ts +++ b/packages/user/src/supertokens/types/index.ts @@ -1,3 +1,10 @@ +import type { FastifyInstance } from "fastify"; +import type { TypeInput as EmailVerificationRecipeConfig } from "supertokens-node/recipe/emailverification/types"; +import type { TypeInput as SessionRecipeConfig } from "supertokens-node/recipe/session/types"; +import type { TypeProvider } from "supertokens-node/recipe/thirdpartyemailpassword"; +import type { TypeInput as ThirdPartyEmailPasswordRecipeConfig } from "supertokens-node/recipe/thirdpartyemailpassword/types"; +import type { TypeInput as UserRolesRecipeConfig } from "supertokens-node/recipe/userroles/types"; + import { Apple, Facebook, @@ -8,31 +15,6 @@ import { import type { EmailVerificationRecipe } from "./emailVerificationRecipe"; import type { SessionRecipe } from "./sessionRecipe"; import type { ThirdPartyEmailPasswordRecipe } from "./thirdPartyEmailPasswordRecipe"; -import type { FastifyInstance } from "fastify"; -import type { TypeInput as EmailVerificationRecipeConfig } from "supertokens-node/recipe/emailverification/types"; -import type { TypeInput as SessionRecipeConfig } from "supertokens-node/recipe/session/types"; -import type { TypeProvider } from "supertokens-node/recipe/thirdpartyemailpassword"; -import type { TypeInput as ThirdPartyEmailPasswordRecipeConfig } from "supertokens-node/recipe/thirdpartyemailpassword/types"; -import type { TypeInput as UserRolesRecipeConfig } from "supertokens-node/recipe/userroles/types"; - -interface SupertokensRecipes { - emailVerification?: - | EmailVerificationRecipe - | ((fastify: FastifyInstance) => EmailVerificationRecipeConfig); - session?: SessionRecipe | ((fastify: FastifyInstance) => SessionRecipeConfig); - userRoles?: (fastify: FastifyInstance) => UserRolesRecipeConfig; - thirdPartyEmailPassword?: - | ThirdPartyEmailPasswordRecipe - | ((fastify: FastifyInstance) => ThirdPartyEmailPasswordRecipeConfig); -} - -interface SupertokensThirdPartyProvider { - apple?: Parameters[0][]; - facebook?: Parameters[0]; - github?: Parameters[0]; - google?: Parameters[0]; - custom?: TypeProvider[]; -} interface SupertokensConfig { apiBasePath?: string; @@ -41,13 +23,32 @@ interface SupertokensConfig { */ checkSessionInDatabase?: boolean; connectionUri: string; + emailVerificationPath?: string; providers?: SupertokensThirdPartyProvider; recipes?: SupertokensRecipes; refreshTokenCookiePath?: string; resetPasswordPath?: string; - emailVerificationPath?: string; sendUserAlreadyExistsWarning?: boolean; setErrorHandler?: boolean; } +interface SupertokensRecipes { + emailVerification?: + | ((fastify: FastifyInstance) => EmailVerificationRecipeConfig) + | EmailVerificationRecipe; + session?: ((fastify: FastifyInstance) => SessionRecipeConfig) | SessionRecipe; + thirdPartyEmailPassword?: + | ((fastify: FastifyInstance) => ThirdPartyEmailPasswordRecipeConfig) + | ThirdPartyEmailPasswordRecipe; + userRoles?: (fastify: FastifyInstance) => UserRolesRecipeConfig; +} + +interface SupertokensThirdPartyProvider { + apple?: Parameters[0][]; + custom?: TypeProvider[]; + facebook?: Parameters[0]; + github?: Parameters[0]; + google?: Parameters[0]; +} + export type { SupertokensConfig, SupertokensRecipes }; diff --git a/packages/user/src/supertokens/types/sessionRecipe.ts b/packages/user/src/supertokens/types/sessionRecipe.ts index 105d78d24..189076f90 100644 --- a/packages/user/src/supertokens/types/sessionRecipe.ts +++ b/packages/user/src/supertokens/types/sessionRecipe.ts @@ -3,9 +3,9 @@ import type { BaseRequest } from "supertokens-node/lib/build/framework"; import type { TypeInput as OpenIdTypeInput } from "supertokens-node/lib/build/recipe/openid/types"; import type { APIInterface, + ErrorHandlers, RecipeInterface, TokenTransferMethod, - ErrorHandlers, } from "supertokens-node/recipe/session/types"; type APIInterfaceWrapper = { @@ -23,27 +23,27 @@ type RecipeInterfaceWrapper = { }; interface SessionRecipe { - useDynamicAccessTokenSigningKey?: boolean; - sessionExpiredStatusCode?: number; - invalidClaimStatusCode?: number; accessTokenPath?: string; - cookieSecure?: boolean; - cookieSameSite?: "strict" | "lax" | "none"; + antiCsrf?: "NONE" | "VIA_CUSTOM_HEADER" | "VIA_TOKEN"; cookieDomain?: string; + cookieSameSite?: "lax" | "none" | "strict"; + cookieSecure?: boolean; + errorHandlers?: ErrorHandlers; + exposeAccessTokenToFrontendInCookieBasedAuth?: boolean; getTokenTransferMethod?: (input: { - req: BaseRequest; forCreateNewSession: boolean; + req: BaseRequest; // eslint-disable-next-line @typescript-eslint/no-explicit-any userContext: any; - }) => TokenTransferMethod | "any"; - errorHandlers?: ErrorHandlers; - antiCsrf?: "VIA_TOKEN" | "VIA_CUSTOM_HEADER" | "NONE"; - exposeAccessTokenToFrontendInCookieBasedAuth?: boolean; + }) => "any" | TokenTransferMethod; + invalidClaimStatusCode?: number; override?: { apis?: APIInterfaceWrapper; functions?: RecipeInterfaceWrapper; openIdFeature?: OpenIdTypeInput["override"]; }; + sessionExpiredStatusCode?: number; + useDynamicAccessTokenSigningKey?: boolean; } export type { APIInterfaceWrapper, RecipeInterfaceWrapper, SessionRecipe }; diff --git a/packages/user/src/supertokens/types/thirdPartyEmailPasswordRecipe.ts b/packages/user/src/supertokens/types/thirdPartyEmailPasswordRecipe.ts index 16102f2b3..dda04aac7 100644 --- a/packages/user/src/supertokens/types/thirdPartyEmailPasswordRecipe.ts +++ b/packages/user/src/supertokens/types/thirdPartyEmailPasswordRecipe.ts @@ -1,5 +1,3 @@ -import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; - import type { FastifyInstance } from "fastify"; import type { EmailDeliveryInterface } from "supertokens-node/lib/build/ingredients/emaildelivery/types"; import type { TypeEmailPasswordPasswordResetEmailDeliveryInput } from "supertokens-node/lib/build/recipe/emailpassword/types"; @@ -9,6 +7,8 @@ import type { TypeInputSignUp, } from "supertokens-node/recipe/thirdpartyemailpassword/types"; +import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; + type APIInterfaceWrapper = { [key in keyof APIInterface]?: ( originalImplementation: APIInterface, @@ -16,11 +16,6 @@ type APIInterfaceWrapper = { ) => APIInterface[key]; }; -type SendEmailWrapper = ( - originalImplementation: EmailDeliveryInterface, - fastify: FastifyInstance, -) => typeof ThirdPartyEmailPassword.sendEmail; - type RecipeInterfaceWrapper = { [key in keyof RecipeInterface]?: ( originalImplementation: RecipeInterface, @@ -28,6 +23,11 @@ type RecipeInterfaceWrapper = { ) => RecipeInterface[key]; }; +type SendEmailWrapper = ( + originalImplementation: EmailDeliveryInterface, + fastify: FastifyInstance, +) => typeof ThirdPartyEmailPassword.sendEmail; + interface ThirdPartyEmailPasswordRecipe { override?: { apis?: APIInterfaceWrapper; diff --git a/packages/user/src/supertokens/utils/__test__/profileValidationClaim.spec.ts b/packages/user/src/supertokens/utils/__test__/profileValidationClaim.spec.ts new file mode 100644 index 000000000..04509ccf1 --- /dev/null +++ b/packages/user/src/supertokens/utils/__test__/profileValidationClaim.spec.ts @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import ProfileValidationClaim from "../profileValidationClaim"; + +const FIXED_NOW = new Date("2024-06-15T12:00:00.000Z").getTime(); + +const makePayload = ( + value: { gracePeriodEndsAt?: number; isVerified: boolean } | undefined, +) => { + if (value === undefined) { + return {}; + } + + return { + profileValidation: { + t: FIXED_NOW, + v: value, + }, + }; +}; + +describe("ProfileValidationClaim", () => { + let claim: ProfileValidationClaim; + + beforeEach(() => { + claim = new ProfileValidationClaim(); + vi.useFakeTimers(); + vi.setSystemTime(FIXED_NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("static properties", () => { + it("has key 'profileValidation'", () => { + expect(ProfileValidationClaim.key).toBe("profileValidation"); + }); + + it("has defaultMaxAgeInSeconds as undefined", () => { + expect(ProfileValidationClaim.defaultMaxAgeInSeconds).toBeUndefined(); + }); + }); + + describe("getValueFromPayload", () => { + it("returns undefined when key is absent from payload", () => { + expect(claim.getValueFromPayload({}, {})).toBeUndefined(); + }); + + it("returns the value when present in payload", () => { + const payload = makePayload({ isVerified: true }); + + expect(claim.getValueFromPayload(payload, {})).toEqual({ + isVerified: true, + }); + }); + }); + + describe("getLastRefetchTime", () => { + it("returns undefined when key is absent from payload", () => { + expect(claim.getLastRefetchTime({}, {})).toBeUndefined(); + }); + + it("returns the timestamp when present in payload", () => { + const payload = makePayload({ isVerified: true }); + + expect(claim.getLastRefetchTime(payload, {})).toBe(FIXED_NOW); + }); + }); + + describe("addToPayload_internal", () => { + it("adds profileValidation to the payload with value and current timestamp", () => { + const result = claim.addToPayload_internal({}, { isVerified: true }, {}); + + expect(result.profileValidation).toBeDefined(); + expect(result.profileValidation.v).toEqual({ isVerified: true }); + expect(result.profileValidation.t).toBe(FIXED_NOW); + }); + + it("preserves existing payload properties", () => { + const result = claim.addToPayload_internal( + { other: "value" }, + { isVerified: true }, + {}, + ); + + expect(result.other).toBe("value"); + }); + }); + + describe("removeFromPayload", () => { + it("removes the profileValidation key from payload", () => { + const payload = makePayload({ isVerified: true }); + const result = claim.removeFromPayload(payload, {}); + + expect(result.profileValidation).toBeUndefined(); + }); + + it("preserves other payload properties", () => { + const payload = { ...makePayload({ isVerified: true }), other: "value" }; + const result = claim.removeFromPayload(payload, {}); + + expect(result.other).toBe("value"); + }); + + it("does not mutate the original payload", () => { + const payload = makePayload({ isVerified: true }); + claim.removeFromPayload(payload, {}); + + expect(payload.profileValidation).toBeDefined(); + }); + }); + + describe("removeFromPayloadByMerge_internal", () => { + it("sets the profileValidation key to null (merge-style removal)", () => { + const payload = makePayload({ isVerified: true }); + const result = claim.removeFromPayloadByMerge_internal(payload, {}); + + expect(result.profileValidation).toBeNull(); + }); + + it("preserves other payload properties", () => { + const payload = { ...makePayload({ isVerified: true }), other: "value" }; + const result = claim.removeFromPayloadByMerge_internal(payload, {}); + + expect(result.other).toBe("value"); + }); + }); + + describe("validators.isVerified", () => { + describe("shouldRefetch", () => { + it("always returns true", () => { + const validator = claim.validators.isVerified(); + + expect(validator.shouldRefetch({})).toBe(true); + }); + }); + + describe("validate", () => { + it("returns isValid: false with 'value does not exist' when claim is absent", async () => { + const validator = claim.validators.isVerified(); + const result = await validator.validate({}, {}); + + expect(result.isValid).toBe(false); + if (!result.isValid) { + expect(result.reason.message).toBe("value does not exist"); + expect(result.reason.expectedValue).toBe(true); + expect(result.reason.actualValue).toBeUndefined(); + } + }); + + it("returns isValid: true when profile is verified", async () => { + const validator = claim.validators.isVerified(); + const payload = makePayload({ isVerified: true }); + + const result = await validator.validate(payload, {}); + + expect(result.isValid).toBe(true); + }); + + it("returns isValid: false with 'User profile is incomplete' when not verified and no grace period", async () => { + const validator = claim.validators.isVerified(); + const payload = makePayload({ isVerified: false }); + + const result = await validator.validate(payload, {}); + + expect(result.isValid).toBe(false); + if (!result.isValid) { + expect(result.reason.message).toBe("User profile is incomplete"); + expect(result.reason.expectedValue).toBe(true); + expect(result.reason.actualValue).toBe(false); + } + }); + + it("returns isValid: true when not verified but still within grace period", async () => { + const validator = claim.validators.isVerified(); + const gracePeriodEndsAt = FIXED_NOW + 1000 * 60 * 60 * 24; // 1 day from now + const payload = makePayload({ gracePeriodEndsAt, isVerified: false }); + + const result = await validator.validate(payload, {}); + + expect(result.isValid).toBe(true); + }); + + it("returns isValid: false when not verified and grace period has expired", async () => { + const validator = claim.validators.isVerified(); + const gracePeriodEndsAt = FIXED_NOW - 1; // 1ms in the past + const payload = makePayload({ gracePeriodEndsAt, isVerified: false }); + + const result = await validator.validate(payload, {}); + + expect(result.isValid).toBe(false); + if (!result.isValid) { + expect(result.reason.message).toBe("User profile is incomplete"); + } + }); + + it("uses the custom id when provided to isVerified()", () => { + const validator = claim.validators.isVerified( + undefined, + "custom-claim-id", + ); + + expect(validator.id).toBe("custom-claim-id"); + }); + + it("defaults to the claim key as validator id", () => { + const validator = claim.validators.isVerified(); + + expect(validator.id).toBe("profileValidation"); + }); + }); + }); +}); diff --git a/packages/user/src/supertokens/utils/createUserContext.ts b/packages/user/src/supertokens/utils/createUserContext.ts index f2c671d77..d5d637998 100644 --- a/packages/user/src/supertokens/utils/createUserContext.ts +++ b/packages/user/src/supertokens/utils/createUserContext.ts @@ -1,8 +1,8 @@ -import { FastifyRequest as SupertokensFastifyRequest } from "supertokens-node/lib/build/framework/fastify/framework"; - import type { FastifyRequest } from "fastify"; import type { SessionRequest } from "supertokens-node/lib/build/framework/fastify"; +import { FastifyRequest as SupertokensFastifyRequest } from "supertokens-node/lib/build/framework/fastify/framework"; + // reference https://github.com/supertokens/supertokens-node/blob/0faebfae435fd661f4b6657e2ca510101da012f5/lib/ts/utils.ts#L143 const createUserContext = ( diff --git a/packages/user/src/supertokens/utils/profileValidationClaim.ts b/packages/user/src/supertokens/utils/profileValidationClaim.ts index 072f53328..be4fd61dc 100644 --- a/packages/user/src/supertokens/utils/profileValidationClaim.ts +++ b/packages/user/src/supertokens/utils/profileValidationClaim.ts @@ -4,12 +4,12 @@ // reference https://github.com/supertokens/supertokens-node/blob/master/lib/ts/recipe/session/claimBaseClasses/primitiveArrayClaim.ts -import { getRequestFromUserContext } from "supertokens-node"; -import { SessionClaim } from "supertokens-node/lib/build/recipe/session/claims"; - import type { SessionRequest } from "supertokens-node/framework/fastify"; import type { SessionClaimValidator } from "supertokens-node/recipe/session"; +import { getRequestFromUserContext } from "supertokens-node"; +import { SessionClaim } from "supertokens-node/lib/build/recipe/session/claims"; + interface Response { gracePeriodEndsAt?: number; isVerified: boolean; @@ -19,6 +19,55 @@ class ProfileValidationClaim extends SessionClaim { public static defaultMaxAgeInSeconds: number | undefined = undefined; public static key = "profileValidation"; + validators = { + isVerified: ( + maxAgeInSeconds: + | number + | undefined = ProfileValidationClaim.defaultMaxAgeInSeconds, + id?: string, + ): SessionClaimValidator => { + return { + claim: this, + id: id ?? this.key, + shouldRefetch: () => true, + validate: async (payload, context) => { + const expectedValue = true; + + const claimValue = this.getValueFromPayload(payload, context); + + if (claimValue === undefined) { + return { + isValid: false, + reason: { + actualValue: undefined, + expectedValue, + message: "value does not exist", + }, + }; + } + + if ( + claimValue.isVerified !== expectedValue && + (claimValue.gracePeriodEndsAt + ? claimValue.gracePeriodEndsAt <= Date.now() + : true) + ) { + return { + isValid: false, + reason: { + actualValue: claimValue.isVerified, + expectedValue, + message: "User profile is incomplete", + }, + }; + } + + return { isValid: true }; + }, + }; + }, + }; + constructor() { super("profileValidation"); } @@ -27,8 +76,8 @@ class ProfileValidationClaim extends SessionClaim { return { ...payload, [this.key]: { - v: value, t: Date.now(), + v: value, }, }; } @@ -97,55 +146,6 @@ class ProfileValidationClaim extends SessionClaim { return res; } - - validators = { - isVerified: ( - maxAgeInSeconds: - | number - | undefined = ProfileValidationClaim.defaultMaxAgeInSeconds, - id?: string, - ): SessionClaimValidator => { - return { - claim: this, - id: id ?? this.key, - shouldRefetch: () => true, - validate: async (payload, context) => { - const expectedValue = true; - - const claimValue = this.getValueFromPayload(payload, context); - - if (claimValue === undefined) { - return { - isValid: false, - reason: { - message: "value does not exist", - expectedValue, - actualValue: undefined, - }, - }; - } - - if ( - claimValue.isVerified !== expectedValue && - (claimValue.gracePeriodEndsAt - ? claimValue.gracePeriodEndsAt <= Date.now() - : true) - ) { - return { - isValid: false, - reason: { - message: "User profile is incomplete", - expectedValue, - actualValue: claimValue.isVerified, - }, - }; - } - - return { isValid: true }; - }, - }; - }, - }; } export default ProfileValidationClaim; diff --git a/packages/user/src/types/config.ts b/packages/user/src/types/config.ts index d98cd658f..1b965494f 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -1,14 +1,15 @@ -import invitationHandlers from "../model/invitations/handlers"; -import InvitationService from "../model/invitations/service"; -import userHandlers from "../model/users/handlers"; -import UserService from "../model/users/service"; +import type { FastifyRequest } from "fastify"; import type { SupertokensConfig } from "../supertokens"; import type { Invitation } from "./invitation"; import type { IsEmailOptions } from "./isEmailOptions"; import type { StrongPasswordOptions } from "./strongPasswordOptions"; import type { User, UserUpdateInput } from "./user"; -import type { FastifyRequest } from "fastify"; + +import invitationHandlers from "../model/invitations/handlers"; +import InvitationService from "../model/invitations/service"; +import userHandlers from "../model/users/handlers"; +import UserService from "../model/users/service"; interface EmailOptions { subject?: string; @@ -37,14 +38,14 @@ interface UserConfig { gracePeriodInDays?: number; }; signUp?: { - /** - * @default true - */ - enabled?: boolean; /** * @default false */ emailVerification?: boolean; + /** + * @default true + */ + enabled?: boolean; }; updateEmail?: { enabled?: boolean; diff --git a/packages/user/src/types/index.ts b/packages/user/src/types/index.ts index 1d243f0a8..ba9be6735 100644 --- a/packages/user/src/types/index.ts +++ b/packages/user/src/types/index.ts @@ -2,15 +2,15 @@ import type { PaginatedList } from "@prefabs.tech/fastify-slonik"; import type { MercuriusContext } from "mercurius"; import type { QueryResultRow } from "slonik"; -interface ChangePasswordInput { - oldPassword?: string; - newPassword?: string; -} - interface ChangeEmailInput { email: string; } +interface ChangePasswordInput { + newPassword?: string; + oldPassword?: string; +} + interface EmailErrorMessages { invalid?: string; required?: string; @@ -28,7 +28,7 @@ interface Resolver { [key: string]: unknown; }, context: MercuriusContext, - ) => Promise>; + ) => Promise | QueryResultRow>; } export type { @@ -39,12 +39,12 @@ export type { Resolver, }; +export type { EmailVerificationRecipe } from "../supertokens/types/emailVerificationRecipe"; +export type { SessionRecipe } from "../supertokens/types/sessionRecipe"; +export type { ThirdPartyEmailPasswordRecipe } from "../supertokens/types/thirdPartyEmailPasswordRecipe"; export * from "./config"; export * from "./invitation"; + export * from "./isEmailOptions"; export * from "./strongPasswordOptions"; export * from "./user"; - -export type { EmailVerificationRecipe } from "../supertokens/types/emailVerificationRecipe"; -export type { SessionRecipe } from "../supertokens/types/sessionRecipe"; -export type { ThirdPartyEmailPasswordRecipe } from "../supertokens/types/thirdPartyEmailPasswordRecipe"; diff --git a/packages/user/src/types/invitation.ts b/packages/user/src/types/invitation.ts index 94769ffb0..30a40ac90 100644 --- a/packages/user/src/types/invitation.ts +++ b/packages/user/src/types/invitation.ts @@ -1,58 +1,58 @@ import type { User } from "./index"; interface Invitation { - id: number; acceptedAt?: number; appId?: number; + createdAt: number; email: string; expiresAt: number; + id: number; invitedBy?: User; invitedById: string; payload?: Record; revokedAt?: number; role: string; token: string; - createdAt: number; updatedAt: number; } -type InvitationCreateInput = Omit< +type InvitationCreateInput = { + expiresAt?: string; + payload?: string; +} & Omit< Invitation, - | "id" | "acceptedAt" + | "createdAt" | "expiresAt" + | "id" | "invitedBy" | "payload" | "revokedAt" | "token" - | "createdAt" | "updatedAt" -> & { - expiresAt?: string; - payload?: string; -}; +>; type InvitationUpdateInput = Partial< - Omit< + { + acceptedAt: string; + expiresAt: string; + revokedAt: string; + } & Omit< Invitation, - | "id" | "acceptedAt" | "appId" + | "createdAt" | "email" | "expiresAt" + | "id" | "invitedBy" | "invitedById" | "payload" | "revokedAt" | "role" | "token" - | "createdAt" | "updatedAt" - > & { - acceptedAt: string; - expiresAt: string; - revokedAt: string; - } + > >; export type { Invitation, InvitationCreateInput, InvitationUpdateInput }; diff --git a/packages/user/src/types/strongPasswordOptions.ts b/packages/user/src/types/strongPasswordOptions.ts index a002fac53..f021b9445 100644 --- a/packages/user/src/types/strongPasswordOptions.ts +++ b/packages/user/src/types/strongPasswordOptions.ts @@ -14,25 +14,25 @@ interface StrongPasswordOptions { minLowercase?: number | undefined; /** - * Minimum number of upercase letters + * Minimum number of numbers * * @default 1 */ - minUppercase?: number | undefined; + minNumbers?: number | undefined; /** - * Minimum number of numbers + * Minimum number of symbols * * @default 1 */ - minNumbers?: number | undefined; + minSymbols?: number | undefined; /** - * Minimum number of symbols + * Minimum number of upercase letters * * @default 1 */ - minSymbols?: number | undefined; + minUppercase?: number | undefined; /** * Whether or not the validator should return the score @@ -42,25 +42,27 @@ interface StrongPasswordOptions { // returnScore?: false | undefined; /** - * Points earned for each unique character + * Points earned for containing lowercase characters * - * @default 1 + * @default 10 */ - pointsPerUnique?: number | undefined; + pointsForContainingLower?: number | undefined; /** - * Point earned for each repeated character + * Points earned for containing numbers + * + * @default 10 * - * @default 0.5 */ - pointsPerRepeat?: number | undefined; + pointsForContainingNumber?: number | undefined; /** - * Points earned for containing lowercase characters + * Points earned for containing symbols * * @default 10 + * */ - pointsForContainingLower?: number | undefined; + pointsForContainingSymbol?: number | undefined; /** * Points earned for containing uppercase characters @@ -70,20 +72,18 @@ interface StrongPasswordOptions { pointsForContainingUpper?: number | undefined; /** - * Points earned for containing numbers - * - * @default 10 + * Point earned for each repeated character * + * @default 0.5 */ - pointsForContainingNumber?: number | undefined; + pointsPerRepeat?: number | undefined; /** - * Points earned for containing symbols - * - * @default 10 + * Points earned for each unique character * + * @default 1 */ - pointsForContainingSymbol?: number | undefined; + pointsPerUnique?: number | undefined; } export type { StrongPasswordOptions }; diff --git a/packages/user/src/types/user.ts b/packages/user/src/types/user.ts index a438b80dd..d44eb8c8c 100644 --- a/packages/user/src/types/user.ts +++ b/packages/user/src/types/user.ts @@ -1,49 +1,49 @@ import type { Multipart } from "@prefabs.tech/fastify-s3"; import type { User as SupertokensUser } from "supertokens-node/recipe/thirdpartyemailpassword"; +interface AuthUser extends SupertokensUser, User {} + interface Photo { id: number; url: string; } interface User { - id: string; deletedAt?: number; disabled: boolean; email: string; + id: string; lastLoginAt: number; - photoId?: number | null; photo?: Photo; + photoId?: null | number; roles?: string[]; signedUpAt: number; } -type UserCreateInput = Partial< +type UserCreateInput = { + lastLoginAt?: string; + signedUpAt?: string; +} & Partial< Omit< User, - "disabled" | "lastLoginAt" | "roles" | "signedUpAt" | "deletedAt" | "photo" + "deletedAt" | "disabled" | "lastLoginAt" | "photo" | "roles" | "signedUpAt" > -> & { - lastLoginAt?: string; - signedUpAt?: string; -}; +>; -type UserUpdateInput = Partial< +type UserUpdateInput = { + lastLoginAt?: string; + photo?: Multipart; +} & Partial< Omit< User, - | "id" + | "deletedAt" | "email" + | "id" | "lastLoginAt" + | "photo" | "roles" | "signedUpAt" - | "deletedAt" - | "photo" > -> & { - lastLoginAt?: string; - photo?: Multipart; -}; - -interface AuthUser extends User, SupertokensUser {} +>; export type { AuthUser, User, UserCreateInput, UserUpdateInput }; diff --git a/packages/user/src/userContext.ts b/packages/user/src/userContext.ts index 6bbda0567..ef482fd68 100644 --- a/packages/user/src/userContext.ts +++ b/packages/user/src/userContext.ts @@ -1,12 +1,12 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; +import type { MercuriusContext } from "mercurius"; + import { wrapResponse } from "supertokens-node/framework/fastify"; import { EmailVerificationClaim } from "supertokens-node/recipe/emailverification"; import Session from "supertokens-node/recipe/session"; import ProfileValidationClaim from "./supertokens/utils/profileValidationClaim"; -import type { FastifyRequest, FastifyReply } from "fastify"; -import type { MercuriusContext } from "mercurius"; - const userContext = async ( context: MercuriusContext, request: FastifyRequest, @@ -14,7 +14,6 @@ const userContext = async ( ) => { try { request.session = (await Session.getSession(request, wrapResponse(reply), { - sessionRequired: false, overrideGlobalClaimValidators: async (globalValidators) => globalValidators.filter( (sessionClaimValidator) => @@ -22,6 +21,7 @@ const userContext = async ( sessionClaimValidator.id, ), ), + sessionRequired: false, })) as (typeof request)["session"]; } catch (error) { if (!Session.Error.isErrorFromSuperTokens(error)) { diff --git a/packages/user/src/validator/__test__/email.spec.ts b/packages/user/src/validator/__test__/email.spec.ts index f95f2b7fd..9470281be 100644 --- a/packages/user/src/validator/__test__/email.spec.ts +++ b/packages/user/src/validator/__test__/email.spec.ts @@ -1,9 +1,9 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + import { beforeEach, describe, expect, it } from "vitest"; import validateEmail from "../email"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; - describe("validateEmail", () => { let config = {} as unknown as ApiConfig; @@ -69,4 +69,17 @@ describe("validateEmail", () => { success: true, }); }); + + it("returns success object when config.user.email is undefined (uses empty options fallback)", () => { + const configWithoutEmail = { + user: {}, + } as unknown as ApiConfig; + + const emailValidation = validateEmail( + "user@example.com", + configWithoutEmail, + ); + + expect(emailValidation).toEqual({ success: true }); + }); }); diff --git a/packages/user/src/validator/__test__/password.spec.ts b/packages/user/src/validator/__test__/password.spec.ts index 85c7aac16..e0b097b69 100644 --- a/packages/user/src/validator/__test__/password.spec.ts +++ b/packages/user/src/validator/__test__/password.spec.ts @@ -1,9 +1,9 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + import { beforeEach, describe, expect, it } from "vitest"; import validatePassword from "../password"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; - describe("validatePassword", () => { let config = {} as unknown as ApiConfig; @@ -77,9 +77,9 @@ describe("validatePassword", () => { config.user.password = { minLength: 1, minLowercase: 1, - minUppercase: 1, minNumbers: 1, minSymbols: 1, + minUppercase: 1, }; const password = "Qwerty12"; @@ -97,9 +97,9 @@ describe("validatePassword", () => { config.user.password = { minLength: 1, minLowercase: 1, - minUppercase: 1, minNumbers: 1, minSymbols: 1, + minUppercase: 1, }; const password = "Qwerty1!"; @@ -115,9 +115,9 @@ describe("validatePassword", () => { config.user.password = { minLength: 2, minLowercase: 2, - minUppercase: 2, minNumbers: 2, minSymbols: 2, + minUppercase: 2, }; const password = "Qwerty12"; @@ -135,9 +135,9 @@ describe("validatePassword", () => { config.user.password = { minLength: 2, minLowercase: 2, - minUppercase: 2, minNumbers: 2, minSymbols: 2, + minUppercase: 2, }; const password = "QwertY12!@"; diff --git a/packages/user/src/validator/email.ts b/packages/user/src/validator/email.ts index 2f6217cfa..dfec33136 100644 --- a/packages/user/src/validator/email.ts +++ b/packages/user/src/validator/email.ts @@ -1,7 +1,7 @@ -import { emailSchema } from "../schemas"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import { emailSchema } from "../schemas"; + const validateEmail = (email: string, config: ApiConfig) => { const result = emailSchema( { diff --git a/packages/user/src/validator/password.ts b/packages/user/src/validator/password.ts index 976acfa11..6e48d9fbb 100644 --- a/packages/user/src/validator/password.ts +++ b/packages/user/src/validator/password.ts @@ -1,15 +1,12 @@ -import { passwordSchema } from "../schemas"; -import { defaultOptions } from "../schemas/password"; +import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { StrongPasswordOptions } from "../types"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; -const getErrorMessage = (options?: StrongPasswordOptions): string => { - let errorMessage = "Password is too weak"; +import { passwordSchema } from "../schemas"; +import { defaultOptions } from "../schemas/password"; - if (!options) { - return errorMessage; - } +const getErrorMessage = (options: StrongPasswordOptions): string => { + let errorMessage = "Password is too weak"; const messages: string[] = []; diff --git a/packages/user/vite.config.ts b/packages/user/vite.config.ts index 0036198c2..cdbb234d8 100644 --- a/packages/user/vite.config.ts +++ b/packages/user/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { dependencies, peerDependencies } from "./package.json"; @@ -26,6 +25,8 @@ export default defineConfig(({ mode }) => { output: { exports: "named", globals: { + "@fastify/cors": "FastifyCors", + "@fastify/formbody": "FastifyFormbody", "@prefabs.tech/fastify-config": "PrefabsTechFastifyConfig", "@prefabs.tech/fastify-error-handler": "PrefabsTechFastifyErrorHandler", @@ -33,8 +34,6 @@ export default defineConfig(({ mode }) => { "@prefabs.tech/fastify-mailer": "PrefabsTechFastifyMailer", "@prefabs.tech/fastify-s3": "PrefabsTechFastifyS3", "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", - "@fastify/cors": "FastifyCors", - "@fastify/formbody": "FastifyFormbody", fastify: "Fastify", "fastify-plugin": "FastifyPlugin", humps: "Humps", @@ -48,9 +47,9 @@ export default defineConfig(({ mode }) => { "supertokens-node/lib/build/recipe/session/claims": "claims", "supertokens-node/lib/build/recipe/session/recipe": "SessionRecipe", "supertokens-node/recipe/emailverification": "EmailVerification", + "supertokens-node/recipe/session": "SupertokensSession", "supertokens-node/recipe/session/framework/fastify": "SupertokensSessionFastify", - "supertokens-node/recipe/session": "SupertokensSession", "supertokens-node/recipe/thirdpartyemailpassword": "SupertokensThirdPartyEmailPassword", "supertokens-node/recipe/userroles": "SupertokensUserRoles", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9601303c0..ab616d4b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,13 @@ settings: autoInstallPeers: false excludeLinksFromLockfile: false +overrides: + typescript-eslint: 8.58.0 + '@typescript-eslint/parser': 8.58.0 + '@typescript-eslint/eslint-plugin': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0 + '@typescript-eslint/utils': 8.58.0 + importers: .: @@ -34,7 +41,7 @@ importers: devDependencies: '@prefabs.tech/eslint-config': specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + version: 0.5.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) '@prefabs.tech/tsconfig': specifier: 0.5.0 version: 0.5.0(@types/node@24.10.13) @@ -47,6 +54,9 @@ importers: eslint: specifier: 9.39.2 version: 9.39.2(jiti@2.6.1) + eslint-plugin-perfectionist: + specifier: 5.8.0 + version: 5.8.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) fastify: specifier: 5.7.4 version: 5.7.4 @@ -80,7 +90,7 @@ importers: devDependencies: '@prefabs.tech/eslint-config': specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + version: 0.5.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) '@prefabs.tech/tsconfig': specifier: 0.5.0 version: 0.5.0(@types/node@24.10.13) @@ -96,6 +106,9 @@ importers: eslint: specifier: 9.39.2 version: 9.39.2(jiti@2.6.1) + eslint-plugin-perfectionist: + specifier: 5.8.0 + version: 5.8.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) fastify: specifier: 5.7.4 version: 5.7.4 @@ -126,7 +139,7 @@ importers: devDependencies: '@prefabs.tech/eslint-config': specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + version: 0.5.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) '@prefabs.tech/fastify-config': specifier: 0.93.5 version: link:../config @@ -151,6 +164,9 @@ importers: eslint: specifier: 9.39.2 version: 9.39.2(jiti@2.6.1) + eslint-plugin-perfectionist: + specifier: 5.8.0 + version: 5.8.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) fastify: specifier: 5.7.4 version: 5.7.4 @@ -163,6 +179,9 @@ importers: mercurius: specifier: 16.7.0 version: 16.7.0(graphql@16.12.0) + pg-mem: + specifier: 3.0.14 + version: 3.0.14(slonik@46.8.0(zod@3.25.76)) prettier: specifier: 3.8.1 version: 3.8.1 @@ -193,7 +212,7 @@ importers: devDependencies: '@prefabs.tech/eslint-config': specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + version: 0.5.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) '@prefabs.tech/fastify-config': specifier: 0.93.5 version: link:../config @@ -212,6 +231,9 @@ importers: eslint: specifier: 9.39.2 version: 9.39.2(jiti@2.6.1) + eslint-plugin-perfectionist: + specifier: 5.8.0 + version: 5.8.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) fastify: specifier: 5.7.4 version: 5.7.4 @@ -263,7 +285,7 @@ importers: devDependencies: '@prefabs.tech/eslint-config': specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + version: 0.5.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) '@prefabs.tech/fastify-config': specifier: 0.93.5 version: link:../config @@ -288,6 +310,9 @@ importers: eslint: specifier: 9.39.2 version: 9.39.2(jiti@2.6.1) + eslint-plugin-perfectionist: + specifier: 5.8.0 + version: 5.8.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) fastify: specifier: 5.7.4 version: 5.7.4 @@ -345,7 +370,7 @@ importers: devDependencies: '@prefabs.tech/eslint-config': specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + version: 0.5.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) '@prefabs.tech/fastify-config': specifier: 0.93.5 version: link:../config @@ -370,6 +395,9 @@ importers: eslint: specifier: 9.39.2 version: 9.39.2(jiti@2.6.1) + eslint-plugin-perfectionist: + specifier: 5.8.0 + version: 5.8.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) fastify: specifier: 5.7.4 version: 5.7.4 @@ -415,7 +443,7 @@ importers: devDependencies: '@prefabs.tech/eslint-config': specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + version: 0.5.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) '@prefabs.tech/fastify-config': specifier: 0.93.5 version: link:../config @@ -440,6 +468,9 @@ importers: eslint: specifier: 9.39.2 version: 9.39.2(jiti@2.6.1) + eslint-plugin-perfectionist: + specifier: 5.8.0 + version: 5.8.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) fastify: specifier: 5.7.4 version: 5.7.4 @@ -447,8 +478,8 @@ importers: specifier: 5.1.0 version: 5.1.0 pg-mem: - specifier: 3.0.12 - version: 3.0.12(slonik@46.8.0(zod@3.25.76)) + specifier: 3.0.14 + version: 3.0.14(slonik@46.8.0(zod@3.25.76)) prettier: specifier: 3.8.1 version: 3.8.1 @@ -479,7 +510,7 @@ importers: devDependencies: '@prefabs.tech/eslint-config': specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + version: 0.5.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) '@prefabs.tech/tsconfig': specifier: 0.5.0 version: 0.5.0(@types/node@24.10.13) @@ -492,6 +523,9 @@ importers: eslint: specifier: 9.39.2 version: 9.39.2(jiti@2.6.1) + eslint-plugin-perfectionist: + specifier: 5.8.0 + version: 5.8.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) fastify: specifier: 5.7.4 version: 5.7.4 @@ -522,7 +556,7 @@ importers: devDependencies: '@prefabs.tech/eslint-config': specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + version: 0.5.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) '@prefabs.tech/fastify-config': specifier: 0.93.5 version: link:../config @@ -559,6 +593,9 @@ importers: eslint: specifier: 9.39.2 version: 9.39.2(jiti@2.6.1) + eslint-plugin-perfectionist: + specifier: 5.8.0 + version: 5.8.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) fastify: specifier: 5.7.4 version: 5.7.4 @@ -779,22 +816,42 @@ packages: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.28.5': resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + '@babel/core@7.28.5': resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} engines: {node: '>=6.9.0'} + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + '@babel/generator@7.28.5': resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.27.2': resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} @@ -803,12 +860,22 @@ packages: resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-transforms@7.28.3': resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -825,11 +892,20 @@ packages: resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} engines: {node: '>=6.9.0'} + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.28.5': resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.28.4': resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} @@ -838,14 +914,26 @@ packages: resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.28.5': resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@commitlint/cli@20.4.1': resolution: {integrity: sha512-uuFKKpc7OtQM+6SRqT+a4kV818o1pS+uvv/gsRhyX7g4x495jg+Q7P0+O9VNGyLXBYP0syksS7gMRDJKcekr6A==} engines: {node: '>=v18'} @@ -1102,8 +1190,8 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/config-helpers@0.4.2': @@ -1114,8 +1202,8 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.3': - resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@9.39.2': @@ -2103,63 +2191,63 @@ packages: '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} - '@typescript-eslint/eslint-plugin@8.54.0': - resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==} + '@typescript-eslint/eslint-plugin@8.58.0': + resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.54.0 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/parser': 8.58.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.54.0': - resolution: {integrity: sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==} + '@typescript-eslint/parser@8.58.0': + resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.54.0': - resolution: {integrity: sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==} + '@typescript-eslint/project-service@8.58.0': + resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.54.0': - resolution: {integrity: sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==} + '@typescript-eslint/scope-manager@8.58.0': + resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.54.0': - resolution: {integrity: sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==} + '@typescript-eslint/tsconfig-utils@8.58.0': + resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.54.0': - resolution: {integrity: sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==} + '@typescript-eslint/type-utils@8.58.0': + resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.54.0': - resolution: {integrity: sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==} + '@typescript-eslint/types@8.58.0': + resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.54.0': - resolution: {integrity: sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==} + '@typescript-eslint/typescript-estree@8.58.0': + resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.54.0': - resolution: {integrity: sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==} + '@typescript-eslint/utils@8.58.0': + resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.54.0': - resolution: {integrity: sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==} + '@typescript-eslint/visitor-keys@8.58.0': + resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@unrs/resolver-binding-android-arm-eabi@1.11.1': @@ -2350,12 +2438,15 @@ packages: ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -2460,8 +2551,8 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - avvio@9.1.0: - resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} axe-core@4.11.1: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} @@ -2480,15 +2571,16 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.8.20: - resolution: {integrity: sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==} - hasBin: true - - baseline-browser-mapping@2.9.19: - resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + baseline-browser-mapping@2.10.13: + resolution: {integrity: sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==} + engines: {node: '>=6.0.0'} hasBin: true before-after-hook@3.0.2: @@ -2507,12 +2599,16 @@ packages: bowser@2.12.1: resolution: {integrity: sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -2714,8 +2810,8 @@ packages: resolution: {integrity: sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==} engines: {node: '>= 0.6'} - cookie@1.0.2: - resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} core-js-compat@3.48.0: @@ -3110,6 +3206,12 @@ packages: peerDependencies: eslint: '>=8.23.0' + eslint-plugin-perfectionist@5.8.0: + resolution: {integrity: sha512-k8uIptWIxkUclonCFGyDzgYs9NI+Qh0a7cUXS3L7IYZDEsjXuimFBVbxXPQQngWqMiaxJRwbtYB4smMGMqF+cw==} + engines: {node: ^20.0.0 || >=22.0.0} + peerDependencies: + eslint: ^8.45.0 || ^9.0.0 || ^10.0.0 + eslint-plugin-prettier@5.5.5: resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -3153,7 +3255,7 @@ packages: engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: '@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 - '@typescript-eslint/parser': ^7.0.0 || ^8.0.0 + '@typescript-eslint/parser': 8.58.0 eslint: ^8.57.0 || ^9.0.0 vue-eslint-parser: ^10.0.0 peerDependenciesMeta: @@ -3174,6 +3276,10 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@9.39.2: resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3188,8 +3294,8 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} esrecurse@4.3.0: @@ -3219,8 +3325,8 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} - expect-type@1.2.2: - resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} extend@3.0.2: @@ -3252,8 +3358,8 @@ packages: fast-json-stringify@5.16.1: resolution: {integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==} - fast-json-stringify@6.1.1: - resolution: {integrity: sha512-DbgptncYEXZqDUOEl4krff4mUiVrTZZVI7BBrQR/T3BqMj/eM1flTC1Uk2uUoLcWCxjT95xKulV/Lc6hhOZsBQ==} + fast-json-stringify@6.3.0: + resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} @@ -3291,6 +3397,9 @@ packages: fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + faye-websocket@0.11.4: resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} engines: {node: '>=0.8.0'} @@ -3326,8 +3435,8 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - find-my-way@9.3.0: - resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==} + find-my-way@9.5.0: + resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} engines: {node: '>=20'} find-up-simple@1.0.1: @@ -3346,8 +3455,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} @@ -3650,6 +3759,10 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-parser-js@0.5.10: resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} @@ -3745,8 +3858,8 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - ipaddr.js@2.2.0: - resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} is-array-buffer@3.0.5: @@ -4239,8 +4352,12 @@ packages: resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} minimatch@5.1.6: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} @@ -4394,6 +4511,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + natural-orderby@5.0.0: + resolution: {integrity: sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==} + engines: {node: '>=18'} + nearley@2.20.1: resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} hasBin: true @@ -4633,8 +4754,8 @@ packages: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} - pg-mem@3.0.12: - resolution: {integrity: sha512-XZG5DsqKPPX0UdYW+ihshNrHzF+x6uEV/50x4jZxQo6iFMl02mYdP1DFnkVwfUKLFgi/bKz5vlVYmDFDGFDEgw==} + pg-mem@3.0.14: + resolution: {integrity: sha512-G9m8OD0A+YS083smidSUJddTX2dEDPT8mRMG3sQGNiGfS/mkvAgd9Kf1/onD5633bFN7HcQK/Tn2x7qjBMFRUQ==} peerDependencies: '@mikro-orm/core': '>=4.5.3' '@mikro-orm/postgresql': '>=4.5.3' @@ -4718,8 +4839,8 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} pino-abstract-transport@1.2.0: @@ -4731,11 +4852,11 @@ packages: pino-std-serializers@6.2.2: resolution: {integrity: sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==} - pino-std-serializers@7.0.0: - resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} - pino@10.3.0: - resolution: {integrity: sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==} + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true pino@8.21.0: @@ -4754,8 +4875,8 @@ packages: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -5024,8 +5145,9 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} - safe-regex2@5.0.0: - resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} + safe-regex2@5.1.0: + resolution: {integrity: sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==} + hasBin: true safe-stable-stringify@2.5.0: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} @@ -5060,6 +5182,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + serialize-error@8.1.0: resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==} engines: {node: '>=10'} @@ -5067,8 +5194,8 @@ packages: serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} - set-cookie-parser@2.7.1: - resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} @@ -5153,8 +5280,8 @@ packages: sonic-boom@3.8.1: resolution: {integrity: sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==} - sonic-boom@4.2.0: - resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} @@ -5202,6 +5329,10 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -5385,8 +5516,8 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - ts-api-utils@2.4.0: - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -5472,12 +5603,12 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typescript-eslint@8.54.0: - resolution: {integrity: sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==} + typescript-eslint@8.58.0: + resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} @@ -6307,8 +6438,16 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.28.5': {} + '@babel/compat-data@7.29.0': {} + '@babel/core@7.28.5': dependencies: '@babel/code-frame': 7.27.1 @@ -6329,6 +6468,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/generator@7.28.5': dependencies: '@babel/parser': 7.28.5 @@ -6337,6 +6496,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-compilation-targets@7.27.2': dependencies: '@babel/compat-data': 7.28.5 @@ -6345,6 +6512,14 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + '@babel/helper-globals@7.28.0': {} '@babel/helper-module-imports@7.27.1': @@ -6354,6 +6529,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -6363,6 +6545,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -6374,10 +6565,19 @@ snapshots: '@babel/template': 7.27.2 '@babel/types': 7.28.5 + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@babel/parser@7.28.5': dependencies: '@babel/types': 7.28.5 + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + '@babel/runtime@7.28.4': {} '@babel/template@7.27.2': @@ -6386,6 +6586,12 @@ snapshots: '@babel/parser': 7.28.5 '@babel/types': 7.28.5 + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@babel/traverse@7.28.5': dependencies: '@babel/code-frame': 7.27.1 @@ -6398,11 +6604,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.28.5': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@commitlint/cli@20.4.1(@types/node@24.10.13)(typescript@5.9.3)': dependencies: '@commitlint/format': 20.4.0 @@ -6621,11 +6844,11 @@ snapshots: '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1': + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -6637,16 +6860,16 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.3': + '@eslint/eslintrc@3.3.5': dependencies: - ajv: 6.12.6 + ajv: 6.14.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 3.1.2 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -6664,7 +6887,7 @@ snapshots: '@fastify/ajv-compiler@4.0.5': dependencies: - ajv: 8.17.1 + ajv: 8.18.0 ajv-formats: 3.0.1 fast-uri: 3.1.0 @@ -6676,7 +6899,7 @@ snapshots: '@fastify/fast-json-stringify-compiler@5.0.3': dependencies: - fast-json-stringify: 6.1.1 + fast-json-stringify: 6.3.0 '@fastify/forwarded@3.0.1': {} @@ -6699,7 +6922,7 @@ snapshots: '@fastify/proxy-addr@5.1.0': dependencies: '@fastify/forwarded': 3.0.1 - ipaddr.js: 2.2.0 + ipaddr.js: 2.3.0 '@fastify/send@4.1.0': dependencies: @@ -6715,7 +6938,7 @@ snapshots: dequal: 2.0.3 fastify-plugin: 5.1.0 forwarded: 0.2.0 - http-errors: 2.0.0 + http-errors: 2.0.1 type-is: 2.0.1 vary: 1.1.2 @@ -7161,14 +7384,14 @@ snapshots: '@pkgr/core@0.2.9': {} - '@prefabs.tech/eslint-config@0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)': + '@prefabs.tech/eslint-config@0.5.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)': dependencies: '@eslint/js': 9.39.2 eslint: 9.39.2(jiti@2.6.1) eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.32.0) eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-n: 17.20.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-prettier: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1) @@ -7176,11 +7399,11 @@ snapshots: eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-unicorn: 62.0.0(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-vue: 10.7.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1))) + eslint-plugin-vue: 10.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1))) globals: 17.3.0 prettier: 3.8.1 typescript: 5.9.3 - typescript-eslint: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + typescript-eslint: 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vue-eslint-parser: 10.2.0(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - '@stylistic/eslint-plugin' @@ -7843,96 +8066,96 @@ snapshots: '@types/validator@13.15.10': {} - '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/parser': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/type-utils': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 eslint: 9.39.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.54.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) - '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.54.0': + '@typescript-eslint/scope-manager@8.58.0': dependencies: - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 - '@typescript-eslint/tsconfig-utils@8.54.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.54.0': {} + '@typescript-eslint/types@8.58.0': {} - '@typescript-eslint/typescript-estree@8.54.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.54.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 debug: 4.4.3 - minimatch: 9.0.5 - semver: 7.7.3 + minimatch: 10.2.5 + semver: 7.7.4 tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.54.0': + '@typescript-eslint/visitor-keys@8.58.0': dependencies: - '@typescript-eslint/types': 8.54.0 - eslint-visitor-keys: 4.2.1 + '@typescript-eslint/types': 8.58.0 + eslint-visitor-keys: 5.0.1 '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true @@ -8092,7 +8315,7 @@ snapshots: dependencies: ajv: 8.17.1 - ajv@6.12.6: + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -8106,6 +8329,13 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -8226,10 +8456,10 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - avvio@9.1.0: + avvio@9.2.0: dependencies: '@fastify/error': 4.2.0 - fastq: 1.19.1 + fastq: 1.20.1 axe-core@4.11.1: {} @@ -8253,11 +8483,11 @@ snapshots: balanced-match@1.0.2: {} - base64-js@1.5.1: {} + balanced-match@4.0.4: {} - baseline-browser-mapping@2.8.20: {} + base64-js@1.5.1: {} - baseline-browser-mapping@2.9.19: {} + baseline-browser-mapping@2.10.13: {} before-after-hook@3.0.2: {} @@ -8269,7 +8499,7 @@ snapshots: bowser@2.12.1: {} - brace-expansion@1.1.12: + brace-expansion@1.1.13: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 @@ -8278,13 +8508,17 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 browserslist@4.27.0: dependencies: - baseline-browser-mapping: 2.8.20 + baseline-browser-mapping: 2.10.13 caniuse-lite: 1.0.30001751 electron-to-chromium: 1.5.240 node-releases: 2.0.26 @@ -8292,7 +8526,7 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.9.19 + baseline-browser-mapping: 2.10.13 caniuse-lite: 1.0.30001768 electron-to-chromium: 1.5.286 node-releases: 2.0.27 @@ -8497,7 +8731,7 @@ snapshots: cookie@0.4.0: {} - cookie@1.0.2: {} + cookie@1.1.1: {} core-js-compat@3.48.0: dependencies: @@ -8874,7 +9108,7 @@ snapshots: eslint-compat-utils@0.5.1(eslint@9.39.2(jiti@2.6.1)): dependencies: eslint: 9.39.2(jiti@2.6.1) - semver: 7.7.3 + semver: 7.7.4 eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): dependencies: @@ -8889,7 +9123,7 @@ snapshots: eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.32.0): dependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) eslint-import-resolver-node@0.3.9: dependencies: @@ -8910,15 +9144,15 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) @@ -8932,7 +9166,7 @@ snapshots: eslint: 9.39.2(jiti@2.6.1) eslint-compat-utils: 0.5.1(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -8943,11 +9177,11 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 @@ -8955,7 +9189,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -8975,7 +9209,7 @@ snapshots: hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 @@ -8983,7 +9217,7 @@ snapshots: eslint-plugin-n@17.20.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) enhanced-resolve: 5.19.0 eslint: 9.39.2(jiti@2.6.1) eslint-plugin-es-x: 7.8.0(eslint@9.39.2(jiti@2.6.1)) @@ -8991,12 +9225,21 @@ snapshots: globals: 15.15.0 ignore: 5.3.2 minimatch: 9.0.5 - semver: 7.7.3 + semver: 7.7.4 ts-declaration-location: 1.0.7(typescript@5.9.3) transitivePeerDependencies: - supports-color - typescript + eslint-plugin-perfectionist@5.8.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/utils': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + natural-orderby: 5.0.0 + transitivePeerDependencies: + - supports-color + - typescript + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1): dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -9013,8 +9256,8 @@ snapshots: eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)): dependencies: - '@babel/core': 7.28.5 - '@babel/parser': 7.28.5 + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 eslint: 9.39.2(jiti@2.6.1) hermes-parser: 0.25.1 zod: 3.25.76 @@ -9034,7 +9277,7 @@ snapshots: estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 - minimatch: 3.1.2 + minimatch: 3.1.5 object.entries: 1.1.9 object.fromentries: 2.0.8 object.values: 1.2.1 @@ -9054,7 +9297,7 @@ snapshots: clean-regexp: 1.0.0 core-js-compat: 3.48.0 eslint: 9.39.2(jiti@2.6.1) - esquery: 1.6.0 + esquery: 1.7.0 find-up-simple: 1.0.1 globals: 16.5.0 indent-string: 5.0.0 @@ -9063,21 +9306,21 @@ snapshots: pluralize: 8.0.0 regexp-tree: 0.1.27 regjsparser: 0.13.0 - semver: 7.7.3 + semver: 7.7.4 strip-indent: 4.1.1 - eslint-plugin-vue@10.7.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1))): + eslint-plugin-vue@10.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1))): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) eslint: 9.39.2(jiti@2.6.1) natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 7.1.1 - semver: 7.7.3 + semver: 7.7.4 vue-eslint-parser: 10.2.0(eslint@9.39.2(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint-scope@8.4.0: dependencies: @@ -9088,21 +9331,23 @@ snapshots: eslint-visitor-keys@4.2.1: {} + eslint-visitor-keys@5.0.1: {} + eslint@9.39.2(jiti@2.6.1): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.3 + '@eslint/eslintrc': 3.3.5 '@eslint/js': 9.39.2 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - ajv: 6.12.6 + ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -9110,7 +9355,7 @@ snapshots: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - esquery: 1.6.0 + esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 file-entry-cache: 8.0.0 @@ -9121,7 +9366,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -9135,7 +9380,7 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 - esquery@1.6.0: + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -9170,7 +9415,7 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 - expect-type@1.2.2: {} + expect-type@1.3.0: {} extend@3.0.2: {} @@ -9204,10 +9449,10 @@ snapshots: json-schema-ref-resolver: 1.0.1 rfdc: 1.4.1 - fast-json-stringify@6.1.1: + fast-json-stringify@6.3.0: dependencies: '@fastify/merge-json-schemas': 0.2.1 - ajv: 8.17.1 + ajv: 8.18.0 ajv-formats: 3.0.1 fast-uri: 3.1.0 json-schema-ref-resolver: 3.0.0 @@ -9240,15 +9485,15 @@ snapshots: '@fastify/fast-json-stringify-compiler': 5.0.3 '@fastify/proxy-addr': 5.1.0 abstract-logging: 2.0.1 - avvio: 9.1.0 - fast-json-stringify: 6.1.1 - find-my-way: 9.3.0 + avvio: 9.2.0 + fast-json-stringify: 6.3.0 + find-my-way: 9.5.0 light-my-request: 6.6.0 - pino: 10.3.0 + pino: 10.3.1 process-warning: 5.0.0 rfdc: 1.4.1 secure-json-parse: 4.1.0 - semver: 7.7.3 + semver: 7.7.4 toad-cache: 3.7.0 fastparallel@2.4.1: @@ -9260,6 +9505,10 @@ snapshots: dependencies: reusify: 1.1.0 + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + faye-websocket@0.11.4: dependencies: websocket-driver: 0.7.4 @@ -9268,9 +9517,9 @@ snapshots: dependencies: walk-up-path: 3.0.1 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 fetch-blob@3.2.0: dependencies: @@ -9293,11 +9542,11 @@ snapshots: dependencies: to-regex-range: 5.0.1 - find-my-way@9.3.0: + find-my-way@9.5.0: dependencies: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 - safe-regex2: 5.0.0 + safe-regex2: 5.1.0 find-up-simple@1.0.1: {} @@ -9327,10 +9576,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.3 + flatted: 3.4.2 keyv: 4.5.4 - flatted@3.3.3: {} + flatted@3.4.2: {} follow-redirects@1.15.11(debug@4.4.3): optionalDependencies: @@ -9740,6 +9989,14 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-parser-js@0.5.10: {} http-proxy-agent@5.0.0: @@ -9826,7 +10083,7 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 - ipaddr.js@2.2.0: {} + ipaddr.js@2.3.0: {} is-array-buffer@3.0.5: dependencies: @@ -9863,7 +10120,7 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 is-callable@1.2.7: {} @@ -10187,9 +10444,9 @@ snapshots: light-my-request@6.6.0: dependencies: - cookie: 1.0.2 + cookie: 1.1.1 process-warning: 4.0.1 - set-cookie-parser: 2.7.1 + set-cookie-parser: 2.7.2 limiter@1.1.5: {} @@ -10339,9 +10596,13 @@ snapshots: dependencies: '@isaacs/brace-expansion': 5.0.0 - minimatch@3.1.2: + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.13 minimatch@5.1.6: dependencies: @@ -10677,6 +10938,8 @@ snapshots: natural-compare@1.4.0: {} + natural-orderby@5.0.0: {} + nearley@2.20.1: dependencies: commander: 2.20.3 @@ -10908,7 +11171,7 @@ snapshots: pg-int8@1.0.1: {} - pg-mem@3.0.12(slonik@46.8.0(zod@3.25.76)): + pg-mem@3.0.14(slonik@46.8.0(zod@3.25.76)): dependencies: functional-red-black-tree: 1.0.1 immutable: 4.3.7 @@ -10976,7 +11239,7 @@ snapshots: picomatch@2.3.1: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} pino-abstract-transport@1.2.0: dependencies: @@ -10989,20 +11252,20 @@ snapshots: pino-std-serializers@6.2.2: {} - pino-std-serializers@7.0.0: {} + pino-std-serializers@7.1.0: {} - pino@10.3.0: + pino@10.3.1: dependencies: '@pinojs/redact': 0.4.0 atomic-sleep: 1.0.0 on-exit-leak-free: 2.1.2 pino-abstract-transport: 3.0.0 - pino-std-serializers: 7.0.0 + pino-std-serializers: 7.1.0 process-warning: 5.0.0 quick-format-unescaped: 4.0.4 real-require: 0.2.0 safe-stable-stringify: 2.5.0 - sonic-boom: 4.2.0 + sonic-boom: 4.2.1 thread-stream: 4.0.0 pino@8.21.0: @@ -11028,7 +11291,7 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss@8.5.6: + postcss@8.5.8: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -11313,7 +11576,7 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 - safe-regex2@5.0.0: + safe-regex2@5.1.0: dependencies: ret: 0.5.0 @@ -11337,6 +11600,8 @@ snapshots: semver@7.7.3: {} + semver@7.7.4: {} + serialize-error@8.1.0: dependencies: type-fest: 0.20.2 @@ -11345,7 +11610,7 @@ snapshots: dependencies: randombytes: 2.1.0 - set-cookie-parser@2.7.1: {} + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: dependencies: @@ -11482,7 +11747,7 @@ snapshots: dependencies: atomic-sleep: 1.0.0 - sonic-boom@4.2.0: + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -11523,6 +11788,8 @@ snapshots: statuses@2.0.1: {} + statuses@2.0.2: {} + std-env@3.10.0: {} stop-iteration-iterator@1.1.0: @@ -11717,8 +11984,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinypool@1.1.1: {} @@ -11736,13 +12003,13 @@ snapshots: tr46@0.0.3: {} - ts-api-utils@2.4.0(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 ts-declaration-location@1.0.7(typescript@5.9.3): dependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 typescript: 5.9.3 tsconfig-paths@3.15.0: @@ -11842,12 +12109,12 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -11963,9 +12230,9 @@ snapshots: vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1): dependencies: esbuild: 0.25.11 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 rollup: 4.52.5 tinyglobby: 0.2.15 optionalDependencies: @@ -11986,10 +12253,10 @@ snapshots: '@vitest/utils': 3.2.4 chai: 5.3.3 debug: 4.4.3 - expect-type: 1.2.2 + expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 @@ -12022,8 +12289,8 @@ snapshots: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - esquery: 1.6.0 - semver: 7.7.3 + esquery: 1.7.0 + semver: 7.7.4 transitivePeerDependencies: - supports-color diff --git a/renovate.json b/renovate.json index 39a2b6e9a..4bd832f5f 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,4 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:base" - ] + "extends": ["config:base"] } diff --git a/ship.config.js b/ship.config.js index 71454c5e0..f2970e76d 100644 --- a/ship.config.js +++ b/ship.config.js @@ -14,11 +14,12 @@ const flatten = (arr) => arr.reduce((acc, item) => acc.concat(item), []); function expandPackageList(list, dir = ".") { const isPackageIgnored = (package) => { - return expandPackageList(list - .filter(value => value.startsWith("!")) - .map(item => item.slice(1)) + return expandPackageList( + list + .filter((value) => value.startsWith("!")) + .map((item) => item.slice(1)), ).includes(package); - } + }; return flatten( list.map((item) => { @@ -49,8 +50,10 @@ function expandPackageList(list, dir = ".") { } else { return resolve(dir, item); } - }) - ).filter((package) => !isPackageIgnored(package)).filter(hasPackageJson) + }), + ) + .filter((package) => !isPackageIgnored(package)) + .filter(hasPackageJson); } // ship.js config diff --git a/turbo.json b/turbo.json index 776336957..af707e949 100644 --- a/turbo.json +++ b/turbo.json @@ -2,45 +2,27 @@ "$schema": "https://turbo.build/schema.json", "tasks": { "build": { - "dependsOn": [ - "^build" - ], - "outputs": [ - "dist/**" - ] + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "clean": { + "cache": false }, "lint": { - "env": [ - "NODE_ENV" - ], + "env": ["NODE_ENV"], "outputs": [] }, "lint:fix": { "outputs": [] }, - "publish": { - "outputs": [] - }, - "release": { - "outputs": [] - }, "sort-package": { "outputs": [] }, "test": { - "outputs": [] - }, - "test:ci": { - "outputs": [] - }, - "test:integration": { - "outputs": [] - }, - "test:unit": { - "outputs": [] + "outputs": ["coverage/**"] }, "typecheck": { "outputs": [] } } -} \ No newline at end of file +}