From d20d9595c9c903cde42ae23ea315bfc89c44e2e0 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Wed, 1 Jul 2026 10:10:50 +0300 Subject: [PATCH] feat: initial tags --- .../sweet-cyan-leaf-document-template-tags.md | 382 ++++++++++++++++++ .gitignore | 2 + .../general/envelope-tags-section.tsx | 27 ++ .../app/components/tables/documents-table.tsx | 8 + .../app/components/tables/templates-table.tsx | 13 + .../t.$teamUrl+/documents.$id._index.tsx | 5 + .../t.$teamUrl+/documents._index.tsx | 30 +- .../documents.tag.$tagId._index.tsx | 5 + .../t.$teamUrl+/templates.$id._index.tsx | 5 + .../t.$teamUrl+/templates._index.tsx | 34 +- .../templates.tag.$tagId._index.tsx | 5 + .../server-only/document/find-documents.ts | 32 +- .../lib/server-only/document/get-stats.ts | 26 +- packages/lib/server-only/tag/create-tag.ts | 51 +++ packages/lib/server-only/tag/delete-tag.ts | 34 ++ packages/lib/server-only/tag/find-tags.ts | 47 +++ .../lib/server-only/tag/get-envelope-tags.ts | 44 ++ .../lib/server-only/tag/set-envelope-tags.ts | 98 +++++ packages/lib/server-only/tag/update-tag.ts | 71 ++++ .../template/find-organisation-templates.ts | 5 + .../server-only/template/find-templates.ts | 12 +- packages/lib/types/document.ts | 3 +- packages/lib/types/tag-type.ts | 9 + packages/lib/types/tag.ts | 10 + packages/lib/types/template.ts | 3 +- packages/lib/utils/document.ts | 3 + packages/lib/utils/tags.ts | 7 + .../migration.sql | 53 +++ packages/prisma/schema.prisma | 39 ++ .../find-documents-internal.ts | 6 + .../find-documents-internal.types.ts | 2 + .../server/document-router/find-documents.ts | 4 +- .../document-router/find-documents.types.ts | 1 + .../document-router/get-documents-by-ids.ts | 5 + packages/trpc/server/router.ts | 2 + packages/trpc/server/tag-router/router.ts | 217 ++++++++++ packages/trpc/server/tag-router/schema.ts | 61 +++ .../template-router/get-templates-by-ids.ts | 7 + .../trpc/server/template-router/router.ts | 3 + .../trpc/server/template-router/schema.ts | 2 + packages/ui/primitives/tag/tag-badge.tsx | 24 ++ packages/ui/primitives/tag/tag-filter.tsx | 77 ++++ packages/ui/primitives/tag/tag-input.tsx | 162 ++++++++ packages/ui/primitives/tag/tag-list.tsx | 32 ++ 44 files changed, 1649 insertions(+), 19 deletions(-) create mode 100644 .agents/plans/sweet-cyan-leaf-document-template-tags.md create mode 100644 apps/remix/app/components/general/envelope-tags-section.tsx create mode 100644 apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.tag.$tagId._index.tsx create mode 100644 apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.tag.$tagId._index.tsx create mode 100644 packages/lib/server-only/tag/create-tag.ts create mode 100644 packages/lib/server-only/tag/delete-tag.ts create mode 100644 packages/lib/server-only/tag/find-tags.ts create mode 100644 packages/lib/server-only/tag/get-envelope-tags.ts create mode 100644 packages/lib/server-only/tag/set-envelope-tags.ts create mode 100644 packages/lib/server-only/tag/update-tag.ts create mode 100644 packages/lib/types/tag-type.ts create mode 100644 packages/lib/types/tag.ts create mode 100644 packages/lib/utils/tags.ts create mode 100644 packages/prisma/migrations/20260629120000_add_document_template_tags/migration.sql create mode 100644 packages/trpc/server/tag-router/router.ts create mode 100644 packages/trpc/server/tag-router/schema.ts create mode 100644 packages/ui/primitives/tag/tag-badge.tsx create mode 100644 packages/ui/primitives/tag/tag-filter.tsx create mode 100644 packages/ui/primitives/tag/tag-input.tsx create mode 100644 packages/ui/primitives/tag/tag-list.tsx diff --git a/.agents/plans/sweet-cyan-leaf-document-template-tags.md b/.agents/plans/sweet-cyan-leaf-document-template-tags.md new file mode 100644 index 000000000..e6af4075b --- /dev/null +++ b/.agents/plans/sweet-cyan-leaf-document-template-tags.md @@ -0,0 +1,382 @@ +--- +date: 2026-06-29 +title: Document Template Tags +--- + +## Problem + +Users currently organise documents and templates using **folders** (a hierarchical, single-parent structure). There is no way to apply **flat, cross-cutting labels** (tags) to envelopes — e.g. "urgent", "invoice", "contract", "HR". This makes it hard to filter, group, and find documents/templates across folder boundaries. + +Tags complement folders: a folder answers "where is this?", a tag answers "what kind is this?". An envelope can be in exactly one folder but should be able to carry many tags. + +## Goals + +1. Allow users to create, update, and delete tags scoped to their team. +2. Allow users to assign one or more tags to a document or template. +3. Allow users to filter the documents and templates list pages by tag(s). +4. Display tags on document/template table rows and detail pages. +5. Expose tag management and filtering through tRPC routes (and optionally the V1 public API). +6. Follow the existing **folder** feature as the architectural blueprint so the codebase stays consistent. + +## Design Decisions + +These decisions are informed by the existing `Folder` feature and codebase conventions: + +1. **Tags are team-scoped** — Just like `Folder`, every `Tag` belongs to a `teamId` and a `userId` (creator). This matches `buildTeamWhereQuery` access patterns used throughout the lib layer. + +2. **Tags are type-specific (DOCUMENT / TEMPLATE)** — Following the `FolderType` enum pattern (`DOCUMENT` / `TEMPLATE`). A `TagType` enum mirrors this so document tags and template tags are managed separately, consistent with how folders are split (`documents.folders._index.tsx` vs `templates.folders._index.tsx`). *Alternative considered: shared tags with no type — rejected for consistency with folders and to keep the UI clean.* + +3. **Many-to-many via a join table (`EnvelopeTag`)** — Unlike folders (single `folderId` on `Envelope`), an envelope can have many tags and a tag can be on many envelopes. A join table is required. + +4. **Optional `color` field** — A nullable hex color string (`#RRGGBB`) for visual distinction in the UI. Optional so it's not a breaking concern if omitted. + +5. **Unique constraint `(teamId, name, type)`** — Prevents duplicate tag names within a team for a given type. Names are case-insensitive-normalised on write. + +6. **Filtering semantics: OR (any of)** — When filtering by multiple tags, return envelopes that have **any** of the selected tags. This is the most common tag-filter UX. *Alternative considered: AND (must have all) — noted as a future toggle if needed.* + +7. **Assigning tags replaces the full set** — The `setEnvelopeTags` operation takes an array of tag IDs and sets the envelope's tags to exactly that set (add/remove diff). This is simpler and less error-prone than individual add/remove operations and matches how form state typically works. + +8. **Inline tag creation during assignment** — The assignment UI allows creating a new tag on-the-fly (autocomplete + create), similar to common tag-input UX. The `setEnvelopeTags` route will accept either an existing `tagId` or a `{ name, color }` to create-and-assign in one step. + +9. **`deletedAt` is not needed for tags** — Unlike envelopes, tags are lightweight metadata. Deleting a tag cascades to remove the join-table rows (`onDelete: Cascade`). Envelopes are unaffected. + +10. **V1 public API is a separate, optional phase** — Tag CRUD + filtering is added to tRPC first. V1 API endpoints can follow the same pattern used for folders (`packages/api/v1/`) but are deferred to a later phase to keep this change focused. + +## Scope + +This plan touches four layers: Prisma schema, lib (server-only), tRPC, and the Remix UI. New files are created for the tag lib functions and tag-router; existing files are extended for filtering and display. + +| Layer | New files | Modified files | +| ----- | --------- | -------------- | +| Prisma | schema migration | `packages/prisma/schema.prisma` | +| Lib | `packages/lib/server-only/tag/*` (6 files) | `find-documents.ts`, `find-templates.ts` | +| tRPC | `packages/trpc/server/tag-router/*` (2 files), root router registration | `document-router/find-documents.types.ts`, `template-router` find types, root `trpc.ts` | +| UI | tag primitives + filter component | documents/templates list pages, tables, edit pages | +| V1 API (deferred) | — | — | + +## Changes + +### 1. Database Schema — `packages/prisma/schema.prisma` + +Add a `TagType` enum and `Tag` model, plus an `EnvelopeTag` join table: + +```prisma +enum TagType { + DOCUMENT + TEMPLATE +} + +model Tag { + id String @id @default(cuid()) + name String + color String? + type TagType + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + teamId Int + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + envelopes EnvelopeTag[] + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@unique([teamId, name, type]) + @@index([teamId]) + @@index([teamId, type]) +} + +model EnvelopeTag { + envelopeId String + envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade) + tagId String + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + assignedBy Int + assignedByUser User @relation(fields: [assignedBy], references: [id], onDelete: Cascade) + assignedAt DateTime @default(now()) + + @@id([envelopeId, tagId]) + @@index([tagId]) +} +``` + +Add the back-relations to `Envelope` and `User` and `Team`: + +```prisma +// On Envelope model, add: +tags EnvelopeTag[] + +// On User model, add: +tags Tag[] +envelopeTags EnvelopeTag[] + +// On Team model, add: +tags Tag[] +``` + +**Migration:** Run `npx prisma migrate dev --name add_document_template_tags` (or the project's equivalent migration command). The migration creates both tables, indexes, and the unique constraint. + +### 2. Lib Layer — `packages/lib/server-only/tag/` + +Create a new `tag/` directory mirroring the `folder/` directory structure. Each file follows the existing pattern: import `prisma`, use `AppError`/`AppErrorCode`, use `buildTeamWhereQuery` for team access, and `getTeamById` / `getMemberRoles` for role-based visibility. + +#### `create-tag.ts` +```ts +export interface CreateTagOptions { + userId: number; + teamId: number; + name: string; + color?: string; + type: TTagType; // DOCUMENT | TEMPLATE +} +``` +- Validates team access via `getTeamSettings`. +- Normalises `name` (trim, collapse whitespace). +- Throws `AppError(AppErrorCode.CONFLICT)` on duplicate `(teamId, name, type)` (caught from Prisma unique violation or pre-checked). +- Returns the created `Tag`. + +#### `find-tags.ts` +```ts +export interface FindTagsOptions { + userId: number; + teamId: number; + type?: TTagType; + query?: string; + page?: number; + perPage?: number; +} +``` +- Paginated list of tags for a team, optionally filtered by type and a name search query. +- Uses `buildTeamWhereQuery` for access control. +- Returns `FindResultResponse`. + +#### `update-tag.ts` +```ts +export interface UpdateTagOptions { + userId: number; + teamId: number; + tagId: string; + data: { name?: string; color?: string | null }; +} +``` +- Verifies tag belongs to the user's team. +- Updates name and/or color. +- Re-validates uniqueness if name changes. + +#### `delete-tag.ts` +```ts +export interface DeleteTagOptions { + userId: number; + teamId: number; + tagId: string; +} +``` +- Verifies ownership/team access. +- `prisma.tag.delete` — cascades to `EnvelopeTag` rows automatically. + +#### `set-envelope-tags.ts` +```ts +export interface SetEnvelopeTagsOptions { + userId: number; + teamId: number; + envelopeId: string; + tagIds: string[]; +} +``` +- Fetches the envelope, verifies access (team + visibility, same checks as folder operations). +- Verifies all `tagIds` belong to the same team and match the envelope's type (DOCUMENT tags for documents, TEMPLATE tags for templates). +- Uses a Prisma transaction to diff: delete `EnvelopeTag` rows not in the new set, insert missing ones. +- Returns the updated list of tags on the envelope. + +#### `get-envelope-tags.ts` +```ts +export interface GetEnvelopeTagsOptions { + userId: number; + teamId: number; + envelopeId: string; +} +``` +- Returns all tags assigned to an envelope (with access check). + +#### `types/tag-type.ts` — `packages/lib/types/tag-type.ts` +```ts +import { z } from 'zod'; +export const ZTagTypeSchema = z.enum(['DOCUMENT', 'TEMPLATE']); +export type TTagType = z.infer; +``` +(Mirrors `packages/lib/types/folder-type.ts`.) + +### 3. Filtering — Modify `find-documents.ts` and `find-templates.ts` + +#### `find-documents.ts` (Kysely-based) +- Add `tagIds?: string[]` to `FindDocumentsOptions`. +- When `tagIds` is non-empty, add an `EXISTS` subquery to the WHERE clause: + ```ts + eb.exists( + eb.selectFrom('EnvelopeTag') + .whereRef('EnvelopeTag.envelopeId', '=', 'Envelope.id') + .where('EnvelopeTag.tagId', 'in', sql.join(tagIds.map(sql.lit))) + .select(sql.lit(1).as('one')) + ) + ``` + This implements **OR (any of)** semantics — the envelope matches if it has at least one of the selected tags. +- Hydration step (`prisma.envelope.findMany`) should `include: { tags: { include: { tag: true } } }` so tags are returned with each document. + +#### `find-templates.ts` (Prisma-based) +- Add `tagIds?: string[]` to `FindTemplatesOptions`. +- Add to the `where` clause: + ```ts + tagIds?.length ? { tags: { some: { tagId: { in: tagIds } } } } : undefined + ``` +- Add `tags: { include: { tag: true } }` to `templateInclude`. + +### 4. tRPC Layer — `packages/trpc/server/tag-router/` + +Create `tag-router/router.ts` and `tag-router/schema.ts`, mirroring `folder-router/`. + +#### `tag-router/schema.ts` +```ts +import TagSchema from '@documenso/prisma/generated/zod/modelSchema/TagSchema'; +import { ZTagTypeSchema } from '@documenso/lib/types/tag-type'; +import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; + +export const ZTagSchema = TagSchema.pick({ + id: true, name: true, color: true, type: true, + teamId: true, userId: true, createdAt: true, updatedAt: true, +}); + +export const ZCreateTagRequestSchema = z.object({ + name: z.string().min(1).max(50), + color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(), + type: ZTagTypeSchema, +}); + +export const ZCreateTagResponseSchema = ZTagSchema; + +export const ZUpdateTagRequestSchema = z.object({ + tagId: z.string(), + data: z.object({ + name: z.string().min(1).max(50).optional(), + color: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(), + }), +}); + +export const ZDeleteTagRequestSchema = z.object({ tagId: z.string() }); + +export const ZFindTagsRequestSchema = ZFindSearchParamsSchema.extend({ + type: ZTagTypeSchema.optional(), + query: z.string().optional(), +}); + +export const ZFindTagsResponseSchema = ZFindResultResponse.extend({ + data: z.array(ZTagSchema), +}); + +export const ZSetEnvelopeTagsRequestSchema = z.object({ + envelopeId: z.string(), + tagIds: z.array(z.string()), +}); + +export const ZSetEnvelopeTagsResponseSchema = z.array(ZTagSchema); +``` + +#### `tag-router/router.ts` +Routes following the folder-router conventions (`authenticatedProcedure`, OpenAPI meta with `tags: ['Tag']`, GET/POST only): + +| Route name | Method | Path | Description | +| ---------- | ------ | ---- | ----------- | +| `findTags` | GET | `/tag` | Find tags for the current team | +| `createTag` | POST | `/tag/create` | Create a new tag | +| `updateTag` | POST | `/tag/update` | Update a tag's name/color | +| `deleteTag` | POST | `/tag/delete` | Delete a tag (cascades to assignments) | +| `setEnvelopeTags` | POST | `/tag/assign` | Set the full tag set on an envelope | + +Each route deconstructs `input` on its own line, uses `ctx.teamId` and `ctx.user.id`, and logs via `ctx.logger.info`. + +#### Register the router +In the root tRPC router file (where `folderRouter` is registered), add: +```ts +import { tagRouter } from './tag-router/router'; +// ... +tag: tagRouter, +``` + +#### Update find-documents / find-templates request schemas +- `document-router/find-documents.types.ts`: add `tagIds: z.array(z.string()).optional()` to `ZFindDocumentsRequestSchema`. +- `template-router` find types: add `tagIds: z.array(z.string()).optional()`. +- `document-router/find-documents-internal.types.ts`: add `tagIds` so the UI can filter. + +### 5. UI Layer — Remix app + +#### New primitives (`packages/ui/primitives/tag/`) +- `tag-badge.tsx` — a small coloured pill displaying a tag name (uses `color` if present, falls back to a default Tailwind colour). +- `tag-input.tsx` — an autocomplete multi-select tag input with inline creation (used in edit pages and dialogs). Renders existing tags as removable badges; typing filters suggestions; pressing Enter or selecting creates/assigns. Uses the existing `Command` (cmdk) primitive if available, otherwise a Popover + input pattern. +- `tag-filter.tsx` — a multi-select dropdown for the list pages that adds `tagIds` to the URL search params. + +#### Documents list page — `t.$teamUrl+/documents._index.tsx` +- Add `tagIds` to `ZSearchParamsSchema` (parse from comma-separated query string). +- Pass `tagIds` into the `findDocuments` / `findDocumentsInternal` tRPC query. +- Render `` in the filter toolbar alongside the period selector and search. +- Fetch tags via `trpc.tag.findTags.useQuery({ type: 'DOCUMENT' })` for the filter options. + +#### Templates list page — `t.$teamUrl+/templates._index.tsx` +- Same changes as documents, with `type: 'TEMPLATE'`. + +#### Tables — `documents-table.tsx` and `templates-table.tsx` +- Add a "Tags" column that renders `` for each tag on the row. +- The row data already includes `tags` (from the hydration `include` added in step 3). + +#### Document/template detail pages +- `documents.$id._index.tsx` / `templates.$id._index.tsx`: display assigned tags as badges. +- `documents.$id.edit.tsx` / `templates.$id.edit.tsx`: add a `` field that calls `trpc.tag.setEnvelopeTags.mutate` on change. + +#### Tag management +- A lightweight management UI (rename/delete tags) can be added to team settings or as a small popover from the ``. This can be a minimal first iteration (create via the tag-input inline, delete/rename via a settings page later). + +### 6. V1 Public API (Deferred — Phase 2) + +Once the tRPC + UI layer is stable, add to `packages/api/v1/`: +- `GET /api/v1/tags` — list tags (with `type` query param). +- `POST /api/v1/tags` — create tag. +- `POST /api/v1/tags/assign` — assign tags to an envelope. +- Add `tags` array to the document/template response schemas. +- Add `tagIds` filter to `GET /api/v1/documents`. + +This mirrors exactly how folder support was added to the V1 API (see the `wild-teal-wind-add-folder-support-to-v1-api` plan). + +## Implementation Order + +1. **Prisma schema + migration** — add models, run migration, regenerate client (`zod-prisma-types` will generate `TagSchema`). +2. **Lib layer** — create `tag/` functions; extend `find-documents.ts` and `find-templates.ts` with `tagIds` filter + hydration. +3. **tRPC layer** — create `tag-router`, register it, extend find request schemas. +4. **UI primitives** — `TagBadge`, `TagInput`, `TagFilter`. +5. **List pages** — wire up `TagFilter` + tags column on documents and templates. +6. **Detail/edit pages** — display + assign tags. +7. **E2E tests** — tag CRUD, assign, filter. +8. *(Deferred)* V1 API endpoints. + +## Testing + +E2E tests in `packages/app-tests` following existing Playwright patterns: + +1. Create a tag from the documents tag filter — verify it appears in the list. +2. Assign tags to a document from the edit page — verify badges appear on the table row. +3. Filter documents by a tag — verify only tagged documents show. +4. Filter by multiple tags (OR) — verify union of results. +5. Delete a tag — verify it's removed from all assigned documents. +6. Repeat 1–5 for templates. +7. Verify team scoping — tags from team A are not visible in team B. +8. Verify a DOCUMENT tag cannot be assigned to a TEMPLATE and vice versa. + +## Migration & Compatibility Notes + +- The new `Tag` and `EnvelopeTag` tables are purely additive — no existing columns are modified or removed. +- The `tags` relation added to `Envelope` is additive; existing queries that don't `include` it are unaffected. +- The `tagIds` filter is optional in all find functions; when omitted, behaviour is identical to today. +- No breaking changes to existing tRPC routes or V1 API responses (tags are added as new optional fields). +- The `zod-prisma-types` generator (already configured in `schema.prisma`) will auto-generate `TagSchema` for use in `tag-router/schema.ts`, same as `FolderSchema` is used in `folder-router/schema.ts`. + +## Open Questions + +1. **Tag name uniqueness scope** — Should uniqueness be `(teamId, name, type)` (case-insensitive) or should we allow duplicates and dedupe in the UI? **Recommendation:** enforce in DB (decision #5). +2. **Max tags per envelope** — Should there be a limit (e.g. 10)? **Recommendation:** no hard limit initially; can add a limit later. +3. **Bulk tag assignment** — Should the bulk action bar on the documents page support "add tag to all selected"? **Recommendation:** yes, as a follow-up; the `setEnvelopeTags` lib function can be extended to accept multiple envelope IDs, or a separate `bulkAssignTags` function can be added (mirroring `bulk-move-envelopes`). +4. **Tag colors** — Should we ship a fixed palette picker or allow free-form hex? **Recommendation:** fixed palette of ~8 colours for v1, stored as hex in the `color` column. \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6e1c5420b..29fd72612 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,5 @@ tmp/ # opencode .opencode/package-lock.json + +SUPPORT_KNOWLEDGE_BASE.md diff --git a/apps/remix/app/components/general/envelope-tags-section.tsx b/apps/remix/app/components/general/envelope-tags-section.tsx new file mode 100644 index 000000000..e78267fc5 --- /dev/null +++ b/apps/remix/app/components/general/envelope-tags-section.tsx @@ -0,0 +1,27 @@ +import type { TagType } from '@documenso/lib/types/tag-type'; +import { trpc } from '@documenso/trpc/react'; +import { TagInput } from '@documenso/ui/primitives/tag/tag-input'; +import { Trans } from '@lingui/react/macro'; +import { TagsIcon } from 'lucide-react'; + +export type EnvelopeTagsSectionProps = { + envelopeId: string; + type: (typeof TagType)[keyof typeof TagType]; +}; + +export const EnvelopeTagsSection = ({ envelopeId, type }: EnvelopeTagsSectionProps) => { + const { data: assignedTags } = trpc.tag.getEnvelopeTags.useQuery({ envelopeId }); + + return ( +
+

+ + Tags +

+ +
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/tables/documents-table.tsx b/apps/remix/app/components/tables/documents-table.tsx index 934123318..ba5dec31f 100644 --- a/apps/remix/app/components/tables/documents-table.tsx +++ b/apps/remix/app/components/tables/documents-table.tsx @@ -10,6 +10,7 @@ import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { Skeleton } from '@documenso/ui/primitives/skeleton'; import { TableCell } from '@documenso/ui/primitives/table'; +import { TagList } from '@documenso/ui/primitives/tag/tag-list'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Loader } from 'lucide-react'; @@ -49,6 +50,8 @@ export const DocumentsTable = ({ const { _, i18n } = useLingui(); const team = useCurrentTeam(); + + const documentsPath = formatDocumentsPath(team?.url ?? ''); const [isPending, startTransition] = useTransition(); const updateSearchParams = useUpdateSearchParams(); @@ -96,6 +99,11 @@ export const DocumentsTable = ({ header: _(msg`Sender`), cell: ({ row }) => row.original.user.name ?? row.original.user.email, }, + { + header: _(msg`Tags`), + cell: ({ row }) => `${documentsPath}/tag/${tag.id}`} />, + size: 160, + }, { header: _(msg`Recipient`), accessorKey: 'recipient', diff --git a/apps/remix/app/components/tables/templates-table.tsx b/apps/remix/app/components/tables/templates-table.tsx index cc7754ffd..2927d8a2d 100644 --- a/apps/remix/app/components/tables/templates-table.tsx +++ b/apps/remix/app/components/tables/templates-table.tsx @@ -10,6 +10,7 @@ import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { Skeleton } from '@documenso/ui/primitives/skeleton'; import { TableCell } from '@documenso/ui/primitives/table'; +import { TagList } from '@documenso/ui/primitives/tag/tag-list'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; @@ -32,6 +33,7 @@ type TemplatesTableProps = { documentRootPath: string; templateRootPath: string; enableSelection?: boolean; + enableTagLinks?: boolean; rowSelection?: RowSelectionState; onRowSelectionChange?: (selection: RowSelectionState) => void; }; @@ -45,6 +47,7 @@ export const TemplatesTable = ({ documentRootPath, templateRootPath, enableSelection, + enableTagLinks = true, rowSelection, onRowSelectionChange, }: TemplatesTableProps) => { @@ -109,6 +112,16 @@ export const TemplatesTable = ({ ), }, + { + header: _(msg`Tags`), + cell: ({ row }) => ( + `${templateRootPath}/tag/${tag.id}` : undefined} + /> + ), + size: 160, + }, { header: () => (
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx index 8b68fe82f..8dcaa6b0c 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx @@ -2,6 +2,7 @@ import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/env import { useSession } from '@documenso/lib/client-only/providers/session'; import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { TagType } from '@documenso/lib/types/tag-type'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; @@ -34,6 +35,7 @@ import { } from '~/components/general/document/document-status'; import { EnvelopeRendererFileSelector } from '~/components/general/envelope-editor/envelope-file-selector'; import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer'; +import { EnvelopeTagsSection } from '~/components/general/envelope-tags-section'; import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer'; import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; @@ -252,6 +254,9 @@ export default function DocumentPage({ params }: Route.ComponentProps) { {/* Document information section. */} + {/* Tags section. */} + + {/* Recipients section. */} diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx index 854283075..47ad767e4 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx @@ -2,6 +2,7 @@ import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session- import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { STATS_COUNT_CAP } from '@documenso/lib/constants/document'; import { SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc'; +import { TagType } from '@documenso/lib/types/tag-type'; import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { parseToIntegerArray } from '@documenso/lib/utils/params'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; @@ -12,9 +13,11 @@ import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/docu import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; import type { RowSelectionState } from '@documenso/ui/primitives/data-table'; import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; +import { TagFilter } from '@documenso/ui/primitives/tag/tag-filter'; import { msg } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client'; +import { ChevronLeft } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router'; import { z } from 'zod'; @@ -46,13 +49,18 @@ const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({ query: true, }).extend({ senderIds: z.string().transform(parseToIntegerArray).optional().catch([]), + tagIds: z + .string() + .optional() + .catch(undefined) + .transform((val) => val?.split(',').filter(Boolean)), }); export default function DocumentsPage() { const organisation = useCurrentOrganisation(); const team = useCurrentTeam(); - const { folderId } = useParams(); + const { folderId, tagId } = useParams(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); @@ -85,10 +93,15 @@ export default function DocumentsPage() { [searchParams], ); + // Route param tagId takes priority over URL search param tagIds. + const effectiveTagIds = tagId ? [tagId] : findDocumentSearchParams.tagIds; + const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery( { ...findDocumentSearchParams, folderId, + includeAllFolders: tagId !== undefined, + tagIds: effectiveTagIds, }, { ...SKIP_QUERY_BATCH_META, @@ -114,7 +127,9 @@ export default function DocumentsPage() { let path = formatDocumentsPath(team.url); - if (folderId) { + if (tagId) { + path += `/tag/${tagId}`; + } else if (folderId) { path += `/f/${folderId}`; } @@ -134,7 +149,14 @@ export default function DocumentsPage() { return (
- + {tagId && ( + + + All documents + + )} + + {!tagId && }
@@ -184,6 +206,8 @@ export default function DocumentsPage() { {team && } + {!tagId && } +
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.tag.$tagId._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.tag.$tagId._index.tsx new file mode 100644 index 000000000..ed7863da1 --- /dev/null +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.tag.$tagId._index.tsx @@ -0,0 +1,5 @@ +import DocumentPage, { meta } from './documents._index'; + +export { meta }; + +export default DocumentPage; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx index 45f6d7be9..1975a6e42 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx @@ -1,6 +1,7 @@ import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useSession } from '@documenso/lib/client-only/providers/session'; import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n'; +import { TagType } from '@documenso/lib/types/tag-type'; import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; @@ -21,6 +22,7 @@ import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-l import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog'; import { EnvelopeRendererFileSelector } from '~/components/general/envelope-editor/envelope-file-selector'; import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer'; +import { EnvelopeTagsSection } from '~/components/general/envelope-tags-section'; import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer'; import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; @@ -271,6 +273,9 @@ export default function TemplatePage({ params }: Route.ComponentProps) { {/* Template information section. */} + {/* Tags section. */} + + {/* Recipients section. */} ('templates-bulk-selection', {}); const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false); @@ -63,6 +69,8 @@ export default function TemplatesPage() { page, perPage, folderId, + includeAllFolders: tagId !== undefined, + tagIds: effectiveTagIds.length > 0 ? effectiveTagIds : undefined, }, { enabled: !isOrgView, @@ -92,7 +100,14 @@ export default function TemplatesPage() { return (
- {!isOrgView && } + {tagId && ( + + + All templates + + )} + + {!isOrgView && !tagId && }
@@ -129,6 +144,12 @@ export default function TemplatesPage() {
)} + {!isOrgView && !tagId && ( +
+ +
+ )} +
{activeQuery.data && activeQuery.data.count === 0 ? (
@@ -156,6 +177,7 @@ export default function TemplatesPage() { documentRootPath={documentRootPath} templateRootPath={templateRootPath} enableSelection={!isOrgView} + enableTagLinks={!isOrgView} rowSelection={isOrgView ? {} : rowSelection} onRowSelectionChange={isOrgView ? undefined : setRowSelection} /> diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.tag.$tagId._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.tag.$tagId._index.tsx new file mode 100644 index 000000000..651ab579d --- /dev/null +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.tag.$tagId._index.tsx @@ -0,0 +1,5 @@ +import TemplatePage, { meta } from './templates._index'; + +export { meta }; + +export default TemplatePage; diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 7a0f67bc7..b87aeb92d 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -36,6 +36,8 @@ export type FindDocumentsOptions = { senderIds?: number[]; query?: string; folderId?: string; + includeAllFolders?: boolean; + tagIds?: string[]; /** * When true (default), use a windowed count that caps early for faster pagination. * When false, use a full COUNT(*) for exact totals — preferred for external API consumers. @@ -102,6 +104,22 @@ const senderEmailIs = (eb: EnvelopeExpressionBuilder, email: string) => .select(sql.lit(1).as('one')), ); +export const applyEnvelopeTagFilter = (qb: EnvelopeQueryBuilder, tagIds?: string[]) => { + if (!tagIds || tagIds.length === 0) { + return qb; + } + + return qb.where((eb) => + eb.exists( + eb + .selectFrom('EnvelopeTag') + .whereRef('EnvelopeTag.envelopeId', '=', 'Envelope.id') + .where('EnvelopeTag.tagId', 'in', tagIds) + .select(sql.lit(1).as('one')), + ), + ); +}; + export const findDocuments = async ({ userId, teamId, @@ -115,6 +133,8 @@ export const findDocuments = async ({ senderIds, query = '', folderId, + includeAllFolders = false, + tagIds, useWindowedCount = true, }: FindDocumentsOptions) => { const user = await prisma.user.findFirstOrThrow({ @@ -147,8 +167,12 @@ export const findDocuments = async ({ qb = qb.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT)); // Folder filter - qb = - folderId !== undefined ? qb.where('Envelope.folderId', '=', folderId) : qb.where('Envelope.folderId', 'is', null); + if (!includeAllFolders) { + qb = + folderId !== undefined + ? qb.where('Envelope.folderId', '=', folderId) + : qb.where('Envelope.folderId', 'is', null); + } // Period filter if (period) { @@ -199,6 +223,9 @@ export const findDocuments = async ({ ); } + // Tag filter (OR semantics — envelope must have at least one of the selected tags) + qb = applyEnvelopeTagFilter(qb, tagIds); + return qb; }; @@ -520,6 +547,7 @@ export const findDocuments = async ({ envelopeItems: { select: { id: true, envelopeId: true, title: true, order: true }, }, + tags: { include: { tag: true } }, }, }); diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index c1af421f7..0210fbed7 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -1,4 +1,4 @@ -import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents'; +import { applyEnvelopeTagFilter, type PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents'; import { kyselyPrisma, prisma, sql } from '@documenso/prisma'; import type { DB } from '@documenso/prisma/generated/types'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; @@ -57,7 +57,9 @@ export type GetStatsInput = { period?: PeriodSelectorValue; search?: string; folderId?: string; + includeAllFolders?: boolean; senderIds?: number[]; + tagIds?: string[]; }; /** @@ -80,7 +82,16 @@ const cappedCount = async (qb: EnvelopeQueryBuilder): Promise => { return Math.min(Number(result.total ?? 0), STATS_COUNT_CAP); }; -export const getStats = async ({ userId, teamId, period, search = '', folderId, senderIds }: GetStatsInput) => { +export const getStats = async ({ + userId, + teamId, + period, + search = '', + folderId, + includeAllFolders = false, + senderIds, + tagIds, +}: GetStatsInput) => { const user = await prisma.user.findFirstOrThrow({ where: { id: userId }, select: { id: true, email: true }, @@ -105,8 +116,12 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId, qb = qb.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT)); // Folder filter - qb = - folderId !== undefined ? qb.where('Envelope.folderId', '=', folderId) : qb.where('Envelope.folderId', 'is', null); + if (!includeAllFolders) { + qb = + folderId !== undefined + ? qb.where('Envelope.folderId', '=', folderId) + : qb.where('Envelope.folderId', 'is', null); + } // Period filter if (period) { @@ -121,6 +136,9 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId, qb = qb.where('Envelope.userId', 'in', senderIds); } + // Tag filter (OR semantics — envelope must have at least one of the selected tags) + qb = applyEnvelopeTagFilter(qb, tagIds); + // Search filter if (hasSearch) { qb = qb.where(({ or, eb }) => diff --git a/packages/lib/server-only/tag/create-tag.ts b/packages/lib/server-only/tag/create-tag.ts new file mode 100644 index 000000000..e45072a15 --- /dev/null +++ b/packages/lib/server-only/tag/create-tag.ts @@ -0,0 +1,51 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +import type { TTagType } from '../../types/tag-type'; +import { buildTeamWhereQuery } from '../../utils/teams'; +import { getTeamSettings } from '../team/get-team-settings'; + +export type CreateTagOptions = { + userId: number; + teamId: number; + name: string; + type: TTagType; +}; + +export const createTag = async ({ userId, teamId, name, type }: CreateTagOptions) => { + // This indirectly verifies whether the user has access to the team. + await getTeamSettings({ userId, teamId }); + + const normalizedName = name.trim().replace(/\s+/g, ' '); + const normalizedNameKey = normalizedName.toLowerCase(); + + if (!normalizedName) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: 'Tag name cannot be empty', + }); + } + + const existing = await prisma.tag.findFirst({ + where: { + teamId, + normalizedName: normalizedNameKey, + type, + team: buildTeamWhereQuery({ teamId, userId }), + }, + }); + + if (existing) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, { + message: 'A tag with this name already exists for this type', + }); + } + + return await prisma.tag.create({ + data: { + name: normalizedName, + normalizedName: normalizedNameKey, + type, + teamId, + }, + }); +}; diff --git a/packages/lib/server-only/tag/delete-tag.ts b/packages/lib/server-only/tag/delete-tag.ts new file mode 100644 index 000000000..c22178021 --- /dev/null +++ b/packages/lib/server-only/tag/delete-tag.ts @@ -0,0 +1,34 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +import { buildTeamWhereQuery } from '../../utils/teams'; +import { getTeamById } from '../team/get-team'; + +export type DeleteTagOptions = { + userId: number; + teamId: number; + tagId: string; +}; + +export const deleteTag = async ({ userId, teamId, tagId }: DeleteTagOptions) => { + await getTeamById({ userId, teamId }); + + const tag = await prisma.tag.findFirst({ + where: { + id: tagId, + team: buildTeamWhereQuery({ teamId, userId }), + }, + }); + + if (!tag) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Tag not found', + }); + } + + return await prisma.tag.delete({ + where: { + id: tag.id, + }, + }); +}; diff --git a/packages/lib/server-only/tag/find-tags.ts b/packages/lib/server-only/tag/find-tags.ts new file mode 100644 index 000000000..830531ff0 --- /dev/null +++ b/packages/lib/server-only/tag/find-tags.ts @@ -0,0 +1,47 @@ +import { prisma } from '@documenso/prisma'; +import type { Prisma } from '@prisma/client'; +import type { FindResultResponse } from '../../types/search-params'; +import type { TTagType } from '../../types/tag-type'; +import { buildTeamWhereQuery } from '../../utils/teams'; +import { getTeamById } from '../team/get-team'; + +export type FindTagsOptions = { + userId: number; + teamId: number; + type?: TTagType; + query?: string; + page?: number; + perPage?: number; +}; + +export const findTags = async ({ userId, teamId, type, query, page = 1, perPage = 10 }: FindTagsOptions) => { + await getTeamById({ userId, teamId }); + + const whereClause: Prisma.TagWhereInput = { + team: buildTeamWhereQuery({ teamId, userId }), + type, + name: query ? { contains: query, mode: 'insensitive' } : undefined, + }; + + const [data, count] = await Promise.all([ + prisma.tag.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + createdAt: 'desc', + }, + }), + prisma.tag.count({ + where: whereClause, + }), + ]); + + return { + data, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultResponse; +}; diff --git a/packages/lib/server-only/tag/get-envelope-tags.ts b/packages/lib/server-only/tag/get-envelope-tags.ts new file mode 100644 index 000000000..cda1e2b7b --- /dev/null +++ b/packages/lib/server-only/tag/get-envelope-tags.ts @@ -0,0 +1,44 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; +import { mapEnvelopeTagsToTags } from '../../utils/tags'; +import { getTeamById } from '../team/get-team'; + +export type GetEnvelopeTagsOptions = { + userId: number; + teamId: number; + envelopeId: string; +}; + +export const getEnvelopeTags = async ({ userId, teamId, envelopeId }: GetEnvelopeTagsOptions) => { + const team = await getTeamById({ userId, teamId }); + + const envelope = await prisma.envelope.findFirst({ + where: { + id: envelopeId, + OR: [ + { userId }, + { + teamId: team.id, + visibility: { in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole] }, + }, + ], + }, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Envelope not found', + }); + } + + const envelopeTags = await prisma.envelopeTag.findMany({ + where: { envelopeId }, + include: { + tag: true, + }, + }); + + return mapEnvelopeTagsToTags(envelopeTags); +}; diff --git a/packages/lib/server-only/tag/set-envelope-tags.ts b/packages/lib/server-only/tag/set-envelope-tags.ts new file mode 100644 index 000000000..d62d1d473 --- /dev/null +++ b/packages/lib/server-only/tag/set-envelope-tags.ts @@ -0,0 +1,98 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; +import { EnvelopeType } from '@prisma/client'; + +import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; +import { TagType } from '../../types/tag-type'; +import { mapEnvelopeTagsToTags } from '../../utils/tags'; +import { buildTeamWhereQuery } from '../../utils/teams'; +import { getTeamById } from '../team/get-team'; + +export type SetEnvelopeTagsOptions = { + userId: number; + teamId: number; + envelopeId: string; + tagIds: string[]; +}; + +export const setEnvelopeTags = async ({ userId, teamId, envelopeId, tagIds }: SetEnvelopeTagsOptions) => { + const team = await getTeamById({ userId, teamId }); + + // Verify the envelope exists and the user has access. + const envelope = await prisma.envelope.findFirst({ + where: { + id: envelopeId, + OR: [ + { userId }, + { + teamId: team.id, + visibility: { in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole] }, + }, + ], + }, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Envelope not found', + }); + } + + // Determine the expected tag type based on the envelope type. + const expectedTagType = envelope.type === EnvelopeType.DOCUMENT ? TagType.DOCUMENT : TagType.TEMPLATE; + + // Verify all tagIds belong to the same team and match the envelope type. + if (tagIds.length > 0) { + const tags = await prisma.tag.findMany({ + where: { + id: { in: tagIds }, + team: buildTeamWhereQuery({ teamId, userId }), + type: expectedTagType, + }, + }); + + if (tags.length !== tagIds.length) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: 'One or more tags are invalid or do not match the envelope type', + }); + } + } + + await prisma.$transaction(async (tx) => { + // Delete EnvelopeTag rows not in the new set. + await tx.envelopeTag.deleteMany({ + where: { + envelopeId, + tagId: { notIn: tagIds }, + }, + }); + + // Fetch current assignments to find which ones need to be created. + const existing = await tx.envelopeTag.findMany({ + where: { envelopeId }, + select: { tagId: true }, + }); + + const existingTagIds = new Set(existing.map((et) => et.tagId)); + const toCreate = tagIds.filter((tagId) => !existingTagIds.has(tagId)); + + if (toCreate.length > 0) { + await tx.envelopeTag.createMany({ + data: toCreate.map((tagId) => ({ + envelopeId, + tagId, + assignedBy: userId, + })), + }); + } + }); + + const tags = await prisma.envelopeTag.findMany({ + where: { envelopeId }, + include: { + tag: true, + }, + }); + + return mapEnvelopeTagsToTags(tags); +}; diff --git a/packages/lib/server-only/tag/update-tag.ts b/packages/lib/server-only/tag/update-tag.ts new file mode 100644 index 000000000..efcf744b7 --- /dev/null +++ b/packages/lib/server-only/tag/update-tag.ts @@ -0,0 +1,71 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +import { buildTeamWhereQuery } from '../../utils/teams'; +import { getTeamById } from '../team/get-team'; + +export type UpdateTagOptions = { + userId: number; + teamId: number; + tagId: string; + data: { + name?: string; + }; +}; + +export const updateTag = async ({ userId, teamId, tagId, data }: UpdateTagOptions) => { + const { name } = data; + + await getTeamById({ userId, teamId }); + + const tag = await prisma.tag.findFirst({ + where: { + id: tagId, + team: buildTeamWhereQuery({ teamId, userId }), + }, + }); + + if (!tag) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Tag not found', + }); + } + + if (name !== undefined) { + const normalizedName = name.trim().replace(/\s+/g, ' '); + const normalizedNameKey = normalizedName.toLowerCase(); + + if (!normalizedName) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: 'Tag name cannot be empty', + }); + } + + if (normalizedNameKey !== tag.normalizedName) { + const existing = await prisma.tag.findFirst({ + where: { + teamId, + normalizedName: normalizedNameKey, + type: tag.type, + id: { not: tagId }, + }, + }); + + if (existing) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, { + message: 'A tag with this name already exists for this type', + }); + } + } + + return await prisma.tag.update({ + where: { id: tagId }, + data: { + name: normalizedName, + normalizedName: normalizedNameKey, + }, + }); + } + + return tag; +}; diff --git a/packages/lib/server-only/template/find-organisation-templates.ts b/packages/lib/server-only/template/find-organisation-templates.ts index 77bdddcb7..4ef8f5016 100644 --- a/packages/lib/server-only/template/find-organisation-templates.ts +++ b/packages/lib/server-only/template/find-organisation-templates.ts @@ -58,6 +58,11 @@ export const findOrganisationTemplates = async ({ enabled: true, }, }, + tags: { + include: { + tag: true, + }, + }, } as const; const [data, count] = await Promise.all([ diff --git a/packages/lib/server-only/template/find-templates.ts b/packages/lib/server-only/template/find-templates.ts index 7b838c01c..3880a64ac 100644 --- a/packages/lib/server-only/template/find-templates.ts +++ b/packages/lib/server-only/template/find-templates.ts @@ -13,6 +13,8 @@ export type FindTemplatesOptions = { page?: number; perPage?: number; folderId?: string; + includeAllFolders?: boolean; + tagIds?: string[]; }; export const findTemplates = async ({ @@ -22,6 +24,8 @@ export const findTemplates = async ({ page = 1, perPage = 10, folderId, + includeAllFolders = false, + tagIds, }: FindTemplatesOptions) => { const { teamRole } = await getMemberRoles({ teamId, @@ -46,7 +50,8 @@ export const findTemplates = async ({ { userId, teamId }, ], }, - folderId ? { folderId } : { folderId: null }, + ...(includeAllFolders ? [] : [folderId ? { folderId } : { folderId: null }]), + ...(tagIds && tagIds.length > 0 ? [{ tags: { some: { tagId: { in: tagIds } } } }] : []), ], }; @@ -67,6 +72,11 @@ export const findTemplates = async ({ enabled: true, }, }, + tags: { + include: { + tag: true, + }, + }, } as const; const [data, count] = await Promise.all([ diff --git a/packages/lib/types/document.ts b/packages/lib/types/document.ts index 2205d9191..2f745b3c7 100644 --- a/packages/lib/types/document.ts +++ b/packages/lib/types/document.ts @@ -6,9 +6,9 @@ import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSche import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema'; import { LegacyDocumentSchema } from '@documenso/prisma/types/document-legacy-schema'; import { z } from 'zod'; - import { ZFieldSchema } from './field'; import { ZRecipientLiteSchema } from './recipient'; +import { ZTagLiteSchema } from './tag'; /** * The full document response schema. @@ -169,6 +169,7 @@ export const ZDocumentManySchema = LegacyDocumentSchema.pick({ id: true, url: true, }).nullable(), + tags: ZTagLiteSchema.array(), }); export type TDocumentMany = z.infer; diff --git a/packages/lib/types/tag-type.ts b/packages/lib/types/tag-type.ts new file mode 100644 index 000000000..cc4f21c77 --- /dev/null +++ b/packages/lib/types/tag-type.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const TagType = { + DOCUMENT: 'DOCUMENT', + TEMPLATE: 'TEMPLATE', +} as const; + +export const ZTagTypeSchema = z.enum([TagType.DOCUMENT, TagType.TEMPLATE]); +export type TTagType = z.infer; diff --git a/packages/lib/types/tag.ts b/packages/lib/types/tag.ts new file mode 100644 index 000000000..5fa4b2eaa --- /dev/null +++ b/packages/lib/types/tag.ts @@ -0,0 +1,10 @@ +import TagSchema from '@documenso/prisma/generated/zod/modelSchema/TagSchema'; +import type { z } from 'zod'; + +export const ZTagLiteSchema = TagSchema.pick({ + id: true, + name: true, + type: true, +}); + +export type TTagLite = z.infer; diff --git a/packages/lib/types/template.ts b/packages/lib/types/template.ts index bf43851c9..c04e71fe9 100644 --- a/packages/lib/types/template.ts +++ b/packages/lib/types/template.ts @@ -6,9 +6,9 @@ import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema'; import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema'; import { LegacyTemplateDirectLinkSchema, TemplateSchema } from '@documenso/prisma/types/template-legacy-schema'; import { z } from 'zod'; - import { ZFieldSchema } from './field'; import { ZRecipientLiteSchema } from './recipient'; +import { ZTagLiteSchema } from './tag'; /** * The full template response schema. @@ -156,6 +156,7 @@ export const ZTemplateManySchema = TemplateSchema.pick({ }).nullable(), // Backwards compatibility. templateDocumentDataId: z.string().default(''), + tags: ZTagLiteSchema.array(), }); export type TTemplateMany = z.infer; diff --git a/packages/lib/utils/document.ts b/packages/lib/utils/document.ts index c64a09f67..89f887a28 100644 --- a/packages/lib/utils/document.ts +++ b/packages/lib/utils/document.ts @@ -8,6 +8,7 @@ import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email'; import { SignatureLevel } from '../types/signature-level'; import { mapSecondaryIdToDocumentId } from './envelope'; import { mapRecipientToLegacyRecipient } from './recipients'; +import { type EnvelopeTagWithTag, mapEnvelopeTagsToTags } from './tags'; export const isDocumentCompleted = (document: Pick | DocumentStatus) => { const status = typeof document === 'string' ? document : document.status; @@ -109,6 +110,7 @@ type MapEnvelopeToDocumentManyOptions = Envelope & { user: Pick; team: Pick; recipients: Recipient[]; + tags?: EnvelopeTagWithTag[]; }; /** @@ -150,5 +152,6 @@ export const mapEnvelopesToDocumentMany = (envelope: MapEnvelopeToDocumentManyOp url: envelope.team.url, }, recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)), + tags: mapEnvelopeTagsToTags(envelope.tags), }; }; diff --git a/packages/lib/utils/tags.ts b/packages/lib/utils/tags.ts new file mode 100644 index 000000000..7d5a4be9a --- /dev/null +++ b/packages/lib/utils/tags.ts @@ -0,0 +1,7 @@ +export type EnvelopeTagWithTag = { + tag: TTag; +}; + +export const mapEnvelopeTagsToTags = (envelopeTags?: EnvelopeTagWithTag[] | null) => { + return (envelopeTags ?? []).map((envelopeTag) => envelopeTag.tag); +}; diff --git a/packages/prisma/migrations/20260629120000_add_document_template_tags/migration.sql b/packages/prisma/migrations/20260629120000_add_document_template_tags/migration.sql new file mode 100644 index 000000000..80d472f0b --- /dev/null +++ b/packages/prisma/migrations/20260629120000_add_document_template_tags/migration.sql @@ -0,0 +1,53 @@ +-- CreateEnum +CREATE TYPE "TagType" AS ENUM ('DOCUMENT', 'TEMPLATE'); + +-- CreateTable +CREATE TABLE "Tag" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "normalizedName" TEXT NOT NULL, + "type" "TagType" NOT NULL, + "teamId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Tag_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EnvelopeTag" ( + "envelopeId" TEXT NOT NULL, + "tagId" TEXT NOT NULL, + "assignedBy" INTEGER, + "assignedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "EnvelopeTag_pkey" PRIMARY KEY ("envelopeId", "tagId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Tag_teamId_normalizedName_type_key" ON "Tag"("teamId", "normalizedName", "type"); + +-- CreateIndex +CREATE INDEX "Tag_teamId_idx" ON "Tag"("teamId"); + +-- CreateIndex +CREATE INDEX "Tag_teamId_type_idx" ON "Tag"("teamId", "type"); + +-- CreateIndex +CREATE INDEX "EnvelopeTag_tagId_idx" ON "EnvelopeTag"("tagId"); + +-- AddForeignKey +ALTER TABLE "Tag" ADD CONSTRAINT "Tag_teamId_fkey" + FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE; + +-- AddForeignKey +ALTER TABLE "EnvelopeTag" ADD CONSTRAINT "EnvelopeTag_envelopeId_fkey" + FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE; + +-- AddForeignKey +ALTER TABLE "EnvelopeTag" ADD CONSTRAINT "EnvelopeTag_tagId_fkey" + FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE; + +-- AddForeignKey +ALTER TABLE "EnvelopeTag" ADD CONSTRAINT "EnvelopeTag_assignedBy_fkey" + FOREIGN KEY ("assignedBy") REFERENCES "User"("id") ON DELETE SET NULL; \ No newline at end of file diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 06be2eed9..89f14c9ae 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -66,6 +66,8 @@ model User { folders Folder[] envelopes Envelope[] + envelopeTags EnvelopeTag[] + verificationTokens VerificationToken[] apiTokens ApiToken[] securityAuditLogs UserSecurityAuditLog[] @@ -402,6 +404,40 @@ enum FolderType { TEMPLATE } +enum TagType { + DOCUMENT + TEMPLATE +} + +model Tag { + id String @id @default(cuid()) + name String + normalizedName String + type TagType + teamId Int + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + envelopes EnvelopeTag[] + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@unique([teamId, normalizedName, type]) + @@index([teamId]) + @@index([teamId, type]) +} + +model EnvelopeTag { + envelopeId String + envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade) + tagId String + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + assignedBy Int? + assignedByUser User? @relation(fields: [assignedBy], references: [id], onDelete: SetNull) + assignedAt DateTime @default(now()) + + @@id([envelopeId, tagId]) + @@index([tagId]) +} + model Folder { id String @id @default(cuid()) name String @@ -486,6 +522,8 @@ model Envelope { envelopeAttachments EnvelopeAttachment[] + tags EnvelopeTag[] + @@index([type]) @@index([status]) @@index([userId]) @@ -1052,6 +1090,7 @@ model Team { envelopes Envelope[] folders Folder[] + tags Tag[] apiTokens ApiToken[] webhooks Webhook[] teamGroups TeamGroup[] diff --git a/packages/trpc/server/document-router/find-documents-internal.ts b/packages/trpc/server/document-router/find-documents-internal.ts index ff31b882d..71eb839a4 100644 --- a/packages/trpc/server/document-router/find-documents-internal.ts +++ b/packages/trpc/server/document-router/find-documents-internal.ts @@ -26,6 +26,8 @@ export const findDocumentsInternalRoute = authenticatedProcedure period, senderIds, folderId, + includeAllFolders, + tagIds, } = input; const [stats, documents] = await Promise.all([ @@ -35,7 +37,9 @@ export const findDocumentsInternalRoute = authenticatedProcedure period, search: query, folderId, + includeAllFolders, senderIds, + tagIds, }), findDocuments({ userId: user.id, @@ -49,6 +53,8 @@ export const findDocumentsInternalRoute = authenticatedProcedure period, senderIds, folderId, + includeAllFolders, + tagIds, orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined, }), ]); diff --git a/packages/trpc/server/document-router/find-documents-internal.types.ts b/packages/trpc/server/document-router/find-documents-internal.types.ts index 453daaf2b..2248322ed 100644 --- a/packages/trpc/server/document-router/find-documents-internal.types.ts +++ b/packages/trpc/server/document-router/find-documents-internal.types.ts @@ -10,6 +10,8 @@ export const ZFindDocumentsInternalRequestSchema = ZFindDocumentsRequestSchema.e senderIds: z.array(z.number()).optional(), status: z.nativeEnum(ExtendedDocumentStatus).optional(), folderId: z.string().optional(), + includeAllFolders: z.boolean().optional(), + tagIds: z.array(z.string()).optional(), }); export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({ diff --git a/packages/trpc/server/document-router/find-documents.ts b/packages/trpc/server/document-router/find-documents.ts index 475a6b5b6..64e45988d 100644 --- a/packages/trpc/server/document-router/find-documents.ts +++ b/packages/trpc/server/document-router/find-documents.ts @@ -11,7 +11,8 @@ export const findDocumentsRoute = authenticatedProcedure .query(async ({ input, ctx }) => { const { user, teamId } = ctx; - const { query, templateId, page, perPage, orderByDirection, orderByColumn, source, status, folderId } = input; + const { query, templateId, page, perPage, orderByDirection, orderByColumn, source, status, folderId, tagIds } = + input; const documents = await findDocuments({ userId: user.id, @@ -23,6 +24,7 @@ export const findDocumentsRoute = authenticatedProcedure page, perPage, folderId, + tagIds, orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined, useWindowedCount: false, }); diff --git a/packages/trpc/server/document-router/find-documents.types.ts b/packages/trpc/server/document-router/find-documents.types.ts index 81ee4e3bf..286369939 100644 --- a/packages/trpc/server/document-router/find-documents.types.ts +++ b/packages/trpc/server/document-router/find-documents.types.ts @@ -20,6 +20,7 @@ export const ZFindDocumentsRequestSchema = ZFindSearchParamsSchema.extend({ source: z.nativeEnum(DocumentSource).describe('Filter documents by how it was created.').optional(), status: z.nativeEnum(DocumentStatus).describe('Filter documents by the current status').optional(), folderId: z.string().describe('Filter documents by folder ID').optional(), + tagIds: z.array(z.string()).describe('Filter documents by tag IDs').optional(), orderByColumn: z.enum(['createdAt']).optional(), orderByDirection: z.enum(['asc', 'desc']).describe('').default('desc'), }); diff --git a/packages/trpc/server/document-router/get-documents-by-ids.ts b/packages/trpc/server/document-router/get-documents-by-ids.ts index 309f9381c..adb1716be 100644 --- a/packages/trpc/server/document-router/get-documents-by-ids.ts +++ b/packages/trpc/server/document-router/get-documents-by-ids.ts @@ -55,6 +55,11 @@ export const getDocumentsByIdsRoute = authenticatedProcedure url: true, }, }, + tags: { + include: { + tag: true, + }, + }, }, }); diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index b2327f769..f017d3195 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -10,6 +10,7 @@ import { folderRouter } from './folder-router/router'; import { organisationRouter } from './organisation-router/router'; import { profileRouter } from './profile-router/router'; import { recipientRouter } from './recipient-router/router'; +import { tagRouter } from './tag-router/router'; import { teamRouter } from './team-router/router'; import { templateRouter } from './template-router/router'; import { router } from './trpc'; @@ -29,6 +30,7 @@ export const appRouter = router({ apiToken: apiTokenRouter, team: teamRouter, template: templateRouter, + tag: tagRouter, webhook: webhookRouter, embeddingPresign: embeddingPresignRouter, }); diff --git a/packages/trpc/server/tag-router/router.ts b/packages/trpc/server/tag-router/router.ts new file mode 100644 index 000000000..a3aba4944 --- /dev/null +++ b/packages/trpc/server/tag-router/router.ts @@ -0,0 +1,217 @@ +import { createTag } from '@documenso/lib/server-only/tag/create-tag'; +import { deleteTag } from '@documenso/lib/server-only/tag/delete-tag'; +import { findTags } from '@documenso/lib/server-only/tag/find-tags'; +import { getEnvelopeTags } from '@documenso/lib/server-only/tag/get-envelope-tags'; +import { setEnvelopeTags } from '@documenso/lib/server-only/tag/set-envelope-tags'; +import { updateTag } from '@documenso/lib/server-only/tag/update-tag'; + +import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema'; +import { authenticatedProcedure, router } from '../trpc'; +import { + ZCreateTagRequestSchema, + ZCreateTagResponseSchema, + ZDeleteTagRequestSchema, + ZFindTagsRequestSchema, + ZFindTagsResponseSchema, + ZGetEnvelopeTagsRequestSchema, + ZGetEnvelopeTagsResponseSchema, + ZSetEnvelopeTagsRequestSchema, + ZSetEnvelopeTagsResponseSchema, + ZUpdateTagRequestSchema, + ZUpdateTagResponseSchema, +} from './schema'; + +export const tagRouter = router({ + /** + * @public + */ + findTags: authenticatedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/tag', + summary: 'Find tags', + description: 'Find tags for the current team based on a search criteria', + tags: ['Tag'], + }, + }) + .input(ZFindTagsRequestSchema) + .output(ZFindTagsResponseSchema) + .query(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { type, query, page, perPage } = input; + + ctx.logger.info({ + input: { + type, + query, + }, + }); + + return await findTags({ + userId: user.id, + teamId, + type, + query, + page, + perPage, + }); + }), + + /** + * @private + */ + getEnvelopeTags: authenticatedProcedure + .input(ZGetEnvelopeTagsRequestSchema) + .output(ZGetEnvelopeTagsResponseSchema) + .query(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { envelopeId } = input; + + ctx.logger.info({ + input: { + envelopeId, + }, + }); + + return await getEnvelopeTags({ + userId: user.id, + teamId, + envelopeId, + }); + }), + + /** + * @public + */ + createTag: authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/tag/create', + summary: 'Create tag', + description: 'Creates a new tag in your team', + tags: ['Tag'], + }, + }) + .input(ZCreateTagRequestSchema) + .output(ZCreateTagResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { name, type } = input; + + ctx.logger.info({ + input: { + type, + }, + }); + + return await createTag({ + userId: user.id, + teamId, + name, + type, + }); + }), + + /** + * @public + */ + updateTag: authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/tag/update', + summary: 'Update tag', + description: 'Updates an existing tag', + tags: ['Tag'], + }, + }) + .input(ZUpdateTagRequestSchema) + .output(ZUpdateTagResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { tagId, data } = input; + + ctx.logger.info({ + input: { + tagId, + }, + }); + + return await updateTag({ + userId: user.id, + teamId, + tagId, + data, + }); + }), + + /** + * @public + */ + deleteTag: authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/tag/delete', + summary: 'Delete tag', + description: 'Deletes an existing tag and removes it from all assigned envelopes', + tags: ['Tag'], + }, + }) + .input(ZDeleteTagRequestSchema) + .output(ZSuccessResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { tagId } = input; + + ctx.logger.info({ + input: { + tagId, + }, + }); + + await deleteTag({ + userId: user.id, + teamId, + tagId, + }); + + return ZGenericSuccessResponse; + }), + + /** + * @public + */ + setEnvelopeTags: authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/tag/assign', + summary: 'Set envelope tags', + description: 'Set the full set of tags assigned to an envelope', + tags: ['Tag'], + }, + }) + .input(ZSetEnvelopeTagsRequestSchema) + .output(ZSetEnvelopeTagsResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { envelopeId, tagIds } = input; + + ctx.logger.info({ + input: { + envelopeId, + tagCount: tagIds.length, + }, + }); + + return await setEnvelopeTags({ + userId: user.id, + teamId, + envelopeId, + tagIds, + }); + }), +}); diff --git a/packages/trpc/server/tag-router/schema.ts b/packages/trpc/server/tag-router/schema.ts new file mode 100644 index 000000000..ee2bc77d5 --- /dev/null +++ b/packages/trpc/server/tag-router/schema.ts @@ -0,0 +1,61 @@ +import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { ZTagTypeSchema } from '@documenso/lib/types/tag-type'; +import TagSchema from '@documenso/prisma/generated/zod/modelSchema/TagSchema'; +import { z } from 'zod'; + +export const ZTagSchema = TagSchema.pick({ + id: true, + name: true, + type: true, + teamId: true, + createdAt: true, + updatedAt: true, +}); + +export type TTag = z.infer; + +export const ZCreateTagRequestSchema = z.object({ + name: z.string().min(1).max(50), + type: ZTagTypeSchema, +}); + +export const ZCreateTagResponseSchema = ZTagSchema; + +export const ZUpdateTagRequestSchema = z.object({ + tagId: z.string().describe('The ID of the tag to update'), + data: z.object({ + name: z.string().min(1).max(50).optional().describe('The name of the tag'), + }), +}); + +export const ZUpdateTagResponseSchema = ZTagSchema; + +export const ZDeleteTagRequestSchema = z.object({ + tagId: z.string(), +}); + +export const ZFindTagsRequestSchema = ZFindSearchParamsSchema.extend({ + type: ZTagTypeSchema.optional().describe('Filter tags by type'), + query: z.string().optional().describe('Search tags by name'), +}); + +export const ZFindTagsResponseSchema = ZFindResultResponse.extend({ + data: z.array(ZTagSchema), +}); + +export const ZSetEnvelopeTagsRequestSchema = z.object({ + envelopeId: z.string().describe('The ID of the envelope to assign tags to'), + tagIds: z.array(z.string()).describe('The tag IDs to assign to the envelope'), +}); + +export const ZSetEnvelopeTagsResponseSchema = z.array(ZTagSchema); + +export const ZGetEnvelopeTagsRequestSchema = z.object({ + envelopeId: z.string().describe('The ID of the envelope to get tags for'), +}); + +export const ZGetEnvelopeTagsResponseSchema = z.array(ZTagSchema); + +export type TFindTagsResponse = z.infer; +export type TSetEnvelopeTagsResponse = z.infer; +export type TGetEnvelopeTagsResponse = z.infer; diff --git a/packages/trpc/server/template-router/get-templates-by-ids.ts b/packages/trpc/server/template-router/get-templates-by-ids.ts index ba696f160..a21a39498 100644 --- a/packages/trpc/server/template-router/get-templates-by-ids.ts +++ b/packages/trpc/server/template-router/get-templates-by-ids.ts @@ -2,6 +2,7 @@ import { getMultipleEnvelopeWhereInput } from '@documenso/lib/server-only/envelo import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { mapFieldToLegacyField } from '@documenso/lib/utils/fields'; import { mapRecipientToLegacyRecipient } from '@documenso/lib/utils/recipients'; +import { mapEnvelopeTagsToTags } from '@documenso/lib/utils/tags'; import { prisma } from '@documenso/prisma'; import { EnvelopeType } from '@prisma/client'; @@ -69,6 +70,11 @@ export const getTemplatesByIdsRoute = authenticatedProcedure enabled: true, }, }, + tags: { + include: { + tag: true, + }, + }, }, }); @@ -115,6 +121,7 @@ export const getTemplatesByIdsRoute = authenticatedProcedure } : null, templateDocumentDataId: firstTemplateDocumentData.id, // Backwards compatibility. + tags: mapEnvelopeTagsToTags(envelope.tags), }; }); diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 2ee6c2586..e34b5fe46 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -26,6 +26,7 @@ import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-action import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { mapFieldToLegacyField } from '@documenso/lib/utils/fields'; import { mapRecipientToLegacyRecipient } from '@documenso/lib/utils/recipients'; +import { mapEnvelopeTagsToTags } from '@documenso/lib/utils/tags'; import { mapEnvelopeToTemplateLite } from '@documenso/lib/utils/templates'; import type { Envelope } from '@prisma/client'; import { DocumentDataType, EnvelopeType } from '@prisma/client'; @@ -120,6 +121,7 @@ export const templateRouter = router({ recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)), templateMeta: envelope.documentMeta, directLink: envelope.directLink, + tags: mapEnvelopeTagsToTags(envelope.tags), }; }), }; @@ -167,6 +169,7 @@ export const templateRouter = router({ recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)), templateMeta: envelope.documentMeta, directLink: envelope.directLink, + tags: mapEnvelopeTagsToTags(envelope.tags), }; }), }; diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index 913828b28..40ee6fa12 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -280,6 +280,8 @@ export const ZUpdateTemplateResponseSchema = ZTemplateLiteSchema; export const ZFindTemplatesRequestSchema = ZFindSearchParamsSchema.extend({ type: z.nativeEnum(TemplateType).describe('Filter templates by type.').optional(), folderId: z.string().describe('The ID of the folder to filter templates by.').optional(), + includeAllFolders: z.boolean().describe('Include templates from all folders.').optional(), + tagIds: z.array(z.string()).describe('Filter templates by tag IDs.').optional(), }); export const ZFindOrganisationTemplatesRequestSchema = ZFindSearchParamsSchema; diff --git a/packages/ui/primitives/tag/tag-badge.tsx b/packages/ui/primitives/tag/tag-badge.tsx new file mode 100644 index 000000000..17a09ba69 --- /dev/null +++ b/packages/ui/primitives/tag/tag-badge.tsx @@ -0,0 +1,24 @@ +import type { TTagLite } from '@documenso/lib/types/tag'; +import type { ReactNode } from 'react'; +import { cn } from '../../lib/utils'; + +export type TagBadgeProps = { + tag: Pick; + className?: string; + children?: ReactNode; +}; + +export const TagBadge = ({ tag, className, children }: TagBadgeProps) => { + return ( + + {tag.name} + {children} + + ); +}; diff --git a/packages/ui/primitives/tag/tag-filter.tsx b/packages/ui/primitives/tag/tag-filter.tsx new file mode 100644 index 000000000..22bc07c63 --- /dev/null +++ b/packages/ui/primitives/tag/tag-filter.tsx @@ -0,0 +1,77 @@ +import type { TagType } from '@documenso/lib/types/tag-type'; +import { trpc } from '@documenso/trpc/react'; +import { Trans } from '@lingui/react/macro'; +import { TagIcon } from 'lucide-react'; +import { useSearchParams } from 'react-router'; +import { Button } from '../button'; +import { Checkbox } from '../checkbox'; +import { Popover, PopoverContent, PopoverTrigger } from '../popover'; + +import { TagBadge } from './tag-badge'; + +export type TagFilterProps = { + type: (typeof TagType)[keyof typeof TagType]; +}; + +export const TagFilter = ({ type }: TagFilterProps) => { + const [searchParams] = useSearchParams(); + const [, setSearchParams] = useSearchParams(); + + const { data: tags } = trpc.tag.findTags.useQuery({ + type, + perPage: 100, + }); + + const selectedTagIds = searchParams.get('tagIds')?.split(',').filter(Boolean) ?? []; + + const toggleTag = (tagId: string) => { + const newTagIds = selectedTagIds.includes(tagId) + ? selectedTagIds.filter((id) => id !== tagId) + : [...selectedTagIds, tagId]; + + setSearchParams( + (prev) => { + if (newTagIds.length > 0) { + prev.set('tagIds', newTagIds.join(',')); + } else { + prev.delete('tagIds'); + } + return prev; + }, + { preventScrollReset: true }, + ); + }; + + if (!tags || tags.data.length === 0) { + return null; + } + + return ( + + + + + +
+ {tags.data.map((tag) => ( + + ))} +
+
+
+ ); +}; diff --git a/packages/ui/primitives/tag/tag-input.tsx b/packages/ui/primitives/tag/tag-input.tsx new file mode 100644 index 000000000..4ead1e78b --- /dev/null +++ b/packages/ui/primitives/tag/tag-input.tsx @@ -0,0 +1,162 @@ +import type { TTagLite } from '@documenso/lib/types/tag'; +import type { TagType } from '@documenso/lib/types/tag-type'; +import { trpc } from '@documenso/trpc/react'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { PlusIcon, XIcon } from 'lucide-react'; +import { useState } from 'react'; +import { cn } from '../../lib/utils'; +import { Button } from '../button'; +import { Input } from '../input'; +import { Popover, PopoverContent, PopoverTrigger } from '../popover'; +import { TagBadge } from './tag-badge'; + +export type TagInputProps = { + type: (typeof TagType)[keyof typeof TagType]; + envelopeId: string; + assignedTags: TTagLite[]; + className?: string; +}; + +export const TagInput = ({ type, envelopeId, assignedTags, className }: TagInputProps) => { + const { t } = useLingui(); + + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + + const utils = trpc.useUtils(); + + const { data: tagsData } = trpc.tag.findTags.useQuery({ + type, + perPage: 100, + }); + + const createTagMutation = trpc.tag.createTag.useMutation({ + onSuccess: () => { + void utils.tag.findTags.invalidate(); + }, + }); + + const setEnvelopeTagsMutation = trpc.tag.setEnvelopeTags.useMutation({ + onSuccess: () => { + void utils.tag.getEnvelopeTags.invalidate({ envelopeId }); + }, + }); + + const allTags = tagsData?.data ?? []; + const assignedTagIds = new Set(assignedTags.map((t) => t.id)); + const availableTags = allTags.filter((t) => !assignedTagIds.has(t.id)); + const filteredTags = search + ? availableTags.filter((t) => t.name.toLowerCase().includes(search.toLowerCase())) + : availableTags; + + const exactMatch = allTags.find((t) => t.name.toLowerCase() === search.toLowerCase().trim()); + const canCreate = search.trim().length > 0 && !exactMatch; + + const handleAssignTag = (tagId: string) => { + setEnvelopeTagsMutation.mutate({ + envelopeId, + tagIds: [...assignedTagIds, tagId], + }); + setSearch(''); + setOpen(false); + }; + + const handleUnassignTag = (tagId: string) => { + setEnvelopeTagsMutation.mutate({ + envelopeId, + tagIds: assignedTags.filter((t) => t.id !== tagId).map((t) => t.id), + }); + }; + + const handleCreateAndAssign = async () => { + const tag = await createTagMutation.mutateAsync({ + name: search.trim(), + type, + }); + + handleAssignTag(tag.id); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && canCreate) { + e.preventDefault(); + void handleCreateAndAssign(); + } + }; + + const isMutating = createTagMutation.isPending || setEnvelopeTagsMutation.isPending; + + return ( +
+ {assignedTags.map((tag) => ( + + + + ))} + + + + + + +
+ setSearch(e.target.value)} + onKeyDown={handleKeyDown} + className="h-9 rounded-none border-0 border-b focus-visible:ring-0" + autoFocus + /> + +
+ {filteredTags.length > 0 && ( +
+ {filteredTags.map((tag) => ( + + ))} +
+ )} + + {canCreate && ( + + )} + + {filteredTags.length === 0 && !canCreate && ( +
+ No tags found +
+ )} +
+
+
+
+
+ ); +}; diff --git a/packages/ui/primitives/tag/tag-list.tsx b/packages/ui/primitives/tag/tag-list.tsx new file mode 100644 index 000000000..864a8c775 --- /dev/null +++ b/packages/ui/primitives/tag/tag-list.tsx @@ -0,0 +1,32 @@ +import type { TTagLite } from '@documenso/lib/types/tag'; +import { Link } from 'react-router'; + +import { TagBadge } from './tag-badge'; + +export type TagListProps = { + tags: TTagLite[]; + maxVisible?: number; + getTagHref?: (tag: TTagLite) => string; +}; + +export const TagList = ({ tags, maxVisible = 3, getTagHref }: TagListProps) => { + if (tags.length === 0) { + return null; + } + + return ( +
+ {tags.slice(0, maxVisible).map((tag) => + getTagHref ? ( + + + + ) : ( + + ), + )} + + {tags.length > maxVisible && +{tags.length - maxVisible}} +
+ ); +};