feat: initial tags

This commit is contained in:
Catalin Pit
2026-07-01 10:10:50 +03:00
parent 562d78e2d7
commit d20d9595c9
44 changed files with 1649 additions and 19 deletions
@@ -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 } },
},
});
+22 -4
View File
@@ -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<number> => {
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 }) =>
@@ -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,
},
});
};
@@ -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,
},
});
};
+47
View File
@@ -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<typeof data>;
};
@@ -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);
};
@@ -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);
};
@@ -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;
};
@@ -58,6 +58,11 @@ export const findOrganisationTemplates = async ({
enabled: true,
},
},
tags: {
include: {
tag: true,
},
},
} as const;
const [data, count] = await Promise.all([
@@ -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([
+2 -1
View File
@@ -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<typeof ZDocumentManySchema>;
+9
View File
@@ -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<typeof ZTagTypeSchema>;
+10
View File
@@ -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<typeof ZTagLiteSchema>;
+2 -1
View File
@@ -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<typeof ZTemplateManySchema>;
+3
View File
@@ -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<Envelope, 'status'> | DocumentStatus) => {
const status = typeof document === 'string' ? document : document.status;
@@ -109,6 +110,7 @@ type MapEnvelopeToDocumentManyOptions = Envelope & {
user: Pick<User, 'id' | 'name' | 'email'>;
team: Pick<Team, 'id' | 'url'>;
recipients: Recipient[];
tags?: EnvelopeTagWithTag<TDocumentMany['tags'][number]>[];
};
/**
@@ -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),
};
};
+7
View File
@@ -0,0 +1,7 @@
export type EnvelopeTagWithTag<TTag> = {
tag: TTag;
};
export const mapEnvelopeTagsToTags = <TTag>(envelopeTags?: EnvelopeTagWithTag<TTag>[] | null) => {
return (envelopeTags ?? []).map((envelopeTag) => envelopeTag.tag);
};