feat: add public profiles

This commit is contained in:
David Nguyen
2024-06-06 14:46:48 +10:00
parent d11a68fc4c
commit 5514dad4d8
43 changed files with 2067 additions and 137 deletions

View File

@ -1,3 +1,5 @@
import { env } from 'next-runtime-env';
export enum STRIPE_CUSTOMER_TYPE {
INDIVIDUAL = 'individual',
TEAM = 'team',
@ -8,3 +10,6 @@ export enum STRIPE_PLAN_TYPE {
COMMUNITY = 'community',
ENTERPRISE = 'enterprise',
}
export const STRIPE_COMMUNITY_PLAN_PRODUCT_ID = () =>
env('NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_PRODUCT_ID');

View File

@ -25,6 +25,7 @@ export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_document_page_view_history_sheet: false,
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.
app_public_profile: true,
marketing_header_single_player_mode: false,
marketing_profiles_announcement_bar: true,
} as const;

View File

@ -0,0 +1,162 @@
import { prisma } from '@documenso/prisma';
import type { Template, TemplateDirectLink } from '@documenso/prisma/client';
import {
SubscriptionStatus,
type TeamProfile,
TemplateType,
type UserProfile,
} from '@documenso/prisma/client';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { STRIPE_COMMUNITY_PLAN_PRODUCT_ID } from '../../constants/billing';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { subscriptionsContainsActiveProductId } from '../../utils/billing';
export type GetPublicProfileByUrlOptions = {
profileUrl: string;
};
type PublicDirectLinkTemplate = Template & {
type: 'PUBLIC';
directLink: TemplateDirectLink & {
enabled: true;
};
};
type BaseResponse = {
url: string;
name: string;
badge?: 'Premium' | 'EarlySupporter';
templates: PublicDirectLinkTemplate[];
};
type GetPublicProfileByUrlResponse = BaseResponse &
(
| {
type: 'User';
profile: UserProfile;
}
| {
type: 'Team';
profile: TeamProfile;
}
);
/**
* Get the user or team public profile by URL.
*/
export const getPublicProfileByUrl = async ({
profileUrl,
}: GetPublicProfileByUrlOptions): Promise<GetPublicProfileByUrlResponse> => {
const [user, team] = await Promise.all([
prisma.user.findFirst({
where: {
url: profileUrl,
profile: {
enabled: true,
},
},
include: {
profile: true,
Template: {
where: {
directLink: {
enabled: true,
},
type: TemplateType.PUBLIC,
},
include: {
directLink: true,
},
},
// Subscriptions and _count are used to calculate the badges.
Subscription: {
where: {
status: SubscriptionStatus.ACTIVE,
},
},
_count: {
select: {
teamMembers: true,
},
},
},
}),
prisma.team.findFirst({
where: {
url: profileUrl,
profile: {
enabled: true,
},
},
include: {
profile: true,
templates: {
where: {
directLink: {
enabled: true,
},
type: TemplateType.PUBLIC,
},
include: {
directLink: true,
},
},
},
}),
]);
// Log as critical error.
if (user?.profile && team?.profile) {
console.error('Profile URL is ambiguous', { profileUrl, userId: user.id, teamId: team.id });
throw new AppError(AppErrorCode.INVALID_REQUEST, 'Profile URL is ambiguous');
}
if (user?.profile?.enabled) {
let badge: BaseResponse['badge'] = undefined;
if (user._count.teamMembers > 0) {
badge = 'Premium';
}
const earlyAdopterProductId = STRIPE_COMMUNITY_PLAN_PRODUCT_ID();
if (IS_BILLING_ENABLED() && earlyAdopterProductId) {
const isEarlyAdopter = subscriptionsContainsActiveProductId(user.Subscription, [
earlyAdopterProductId,
]);
if (isEarlyAdopter) {
badge = 'EarlySupporter';
}
}
return {
type: 'User',
badge,
profile: user.profile,
url: profileUrl,
name: user.name || '',
templates: user.Template.filter(
(template): template is PublicDirectLinkTemplate =>
template.directLink?.enabled === true && template.type === TemplateType.PUBLIC,
),
};
}
if (team?.profile?.enabled) {
return {
type: 'Team',
badge: 'Premium',
profile: team.profile,
url: profileUrl,
name: team.name || '',
templates: team.templates.filter(
(template): template is PublicDirectLinkTemplate =>
template.directLink?.enabled === true && template.type === TemplateType.PUBLIC,
),
};
}
throw new AppError(AppErrorCode.NOT_FOUND, 'Profile not found');
};

View File

@ -76,21 +76,36 @@ export const createTeam = async ({
try {
// Create the team directly if no payment is required.
if (!isPaymentRequired) {
await prisma.team.create({
data: {
name: teamName,
url: teamUrl,
ownerUserId: user.id,
customerId,
members: {
create: [
{
userId,
role: TeamMemberRole.ADMIN,
},
],
await prisma.$transaction(async (tx) => {
const existingUserProfileWithUrl = await tx.user.findUnique({
where: {
url: teamUrl,
},
},
select: {
id: true,
},
});
if (existingUserProfileWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'URL already taken.');
}
await tx.team.create({
data: {
name: teamName,
url: teamUrl,
ownerUserId: user.id,
customerId,
members: {
create: [
{
userId,
role: TeamMemberRole.ADMIN,
},
],
},
},
});
});
return {
@ -106,6 +121,19 @@ export const createTeam = async ({
},
});
const existingUserProfileWithUrl = await tx.user.findUnique({
where: {
url: teamUrl,
},
select: {
id: true,
},
});
if (existingUserProfileWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'URL already taken.');
}
if (existingTeamWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
}

View File

@ -0,0 +1,63 @@
import { prisma } from '@documenso/prisma';
import type { TeamProfile } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { updateTeamPublicProfile } from './update-team-public-profile';
export type GetTeamPublicProfileOptions = {
userId: number;
teamId: number;
};
type GetTeamPublicProfileResponse = {
profile: TeamProfile;
url: string | null;
};
export const getTeamPublicProfile = async ({
userId,
teamId,
}: GetTeamPublicProfileOptions): Promise<GetTeamPublicProfileResponse> => {
const team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
profile: true,
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team not found');
}
// Create and return the public profile.
if (!team.profile) {
const { url, profile } = await updateTeamPublicProfile({
userId: userId,
teamId,
data: {
enabled: false,
},
});
if (!profile) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Failed to create public profile');
}
return {
profile,
url,
};
}
return {
profile: team.profile,
url: team.url,
};
};

View File

@ -0,0 +1,38 @@
import { prisma } from '@documenso/prisma';
export type UpdatePublicProfileOptions = {
userId: number;
teamId: number;
data: {
bio?: string;
enabled?: boolean;
};
};
export const updateTeamPublicProfile = async ({
userId,
teamId,
data,
}: UpdatePublicProfileOptions) => {
return await prisma.team.update({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
data: {
profile: {
upsert: {
create: data,
update: data,
},
},
},
include: {
profile: true,
},
});
};

View File

@ -1,11 +1,12 @@
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import type { Prisma, Template } from '@documenso/prisma/client';
export type FindTemplatesOptions = {
userId: number;
teamId?: number;
page: number;
perPage: number;
type?: Template['type'];
page?: number;
perPage?: number;
};
export type FindTemplatesResponse = Awaited<ReturnType<typeof findTemplates>>;
@ -14,12 +15,14 @@ export type FindTemplateRow = FindTemplatesResponse['templates'][number];
export const findTemplates = async ({
userId,
teamId,
type,
page = 1,
perPage = 10,
}: FindTemplatesOptions) => {
let whereFilter: Prisma.TemplateWhereInput = {
userId,
teamId: null,
type,
};
if (teamId !== undefined) {

View File

@ -3,7 +3,7 @@
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import type { TemplateMeta } from '@documenso/prisma/client';
import type { Template, TemplateMeta } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
@ -17,6 +17,9 @@ export type UpdateTemplateSettingsOptions = {
title?: string;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
publicTitle?: string;
publicDescription?: string;
type?: Template['type'];
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
requestMetadata?: RequestMetadata;
@ -29,7 +32,7 @@ export const updateTemplateSettings = async ({
meta,
data,
}: UpdateTemplateSettingsOptions) => {
if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) {
if (Object.values(data).length === 0) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
}
@ -61,30 +64,6 @@ export const updateTemplateSettings = async ({
documentAuth: template.authOptions,
});
const { templateMeta } = template;
const isDateSame = (templateMeta?.dateFormat || null) === (meta?.dateFormat || null);
const isMessageSame = (templateMeta?.message || null) === (meta?.message || null);
const isPasswordSame = (templateMeta?.password || null) === (meta?.password || null);
const isSubjectSame = (templateMeta?.subject || null) === (meta?.subject || null);
const isRedirectUrlSame = (templateMeta?.redirectUrl || null) === (meta?.redirectUrl || null);
const isTimezoneSame = (templateMeta?.timezone || null) === (meta?.timezone || null);
// Early return to avoid unnecessary updates.
if (
template.title === data.title &&
data.globalAccessAuth === documentAuthOption.globalAccessAuth &&
data.globalActionAuth === documentAuthOption.globalActionAuth &&
isDateSame &&
isMessageSame &&
isPasswordSame &&
isSubjectSame &&
isRedirectUrlSame &&
isTimezoneSame
) {
return template;
}
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
@ -120,6 +99,9 @@ export const updateTemplateSettings = async ({
},
data: {
title: data.title,
type: data.type,
publicDescription: data.publicDescription,
publicTitle: data.publicTitle,
authOptions,
templateMeta: {
upsert: {

View File

@ -0,0 +1,55 @@
import { prisma } from '@documenso/prisma';
import type { UserProfile } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { updatePublicProfile } from './update-public-profile';
export type GetUserPublicProfileOptions = {
userId: number;
};
type GetUserPublicProfileResponse = {
profile: UserProfile;
url: string | null;
};
export const getUserPublicProfile = async ({
userId,
}: GetUserPublicProfileOptions): Promise<GetUserPublicProfileResponse> => {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
include: {
profile: true,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
}
// Create and return the public profile.
if (!user.profile) {
const { url, profile } = await updatePublicProfile({
userId: user.id,
data: {
enabled: false,
},
});
if (!profile) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Failed to create public profile');
}
return {
profile,
url,
};
}
return {
profile: user.profile,
url: user.url,
};
};

View File

@ -4,28 +4,65 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
export type UpdatePublicProfileOptions = {
userId: number;
url: string;
data: {
url?: string;
bio?: string;
enabled?: boolean;
};
};
export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOptions) => {
const isUrlTaken = await prisma.user.findFirst({
select: {
id: true,
},
export const updatePublicProfile = async ({ userId, data }: UpdatePublicProfileOptions) => {
if (Object.values(data).length === 0) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
}
const { url, bio, enabled } = data;
const user = await prisma.user.findFirst({
where: {
id: {
not: userId,
},
url,
id: userId,
},
});
if (isUrlTaken) {
throw new AppError(
AppErrorCode.PROFILE_URL_TAKEN,
'Profile username is taken',
'The profile username is already taken',
);
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
}
const finalUrl = url ?? user.url;
if (!finalUrl && enabled) {
throw new AppError(AppErrorCode.INVALID_REQUEST, 'Cannot enable a profile without a URL');
}
if (url) {
const isUrlTakenByAnotherUser = await prisma.user.findFirst({
select: {
id: true,
},
where: {
id: {
not: userId,
},
url,
},
});
const isUrlTakenByAnotherTeam = await prisma.team.findFirst({
select: {
id: true,
},
where: {
url,
},
});
if (isUrlTakenByAnotherUser || isUrlTakenByAnotherTeam) {
throw new AppError(
AppErrorCode.PROFILE_URL_TAKEN,
'Profile username is taken',
'The profile username is already taken',
);
}
}
return await prisma.user.update({
@ -34,16 +71,21 @@ export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOp
},
data: {
url,
userProfile: {
profile: {
upsert: {
create: {
bio: '',
bio,
enabled,
},
update: {
bio: '',
bio,
enabled,
},
},
},
},
include: {
profile: true,
},
});
};

View File

@ -16,6 +16,18 @@ export const subscriptionsContainsActivePlan = (
subscription.status === SubscriptionStatus.ACTIVE && priceIds.includes(subscription.priceId),
);
};
/**
* Returns true if there is a subscription that is active and is one of the provided product IDs.
*/
export const subscriptionsContainsActiveProductId = (
subscriptions: Subscription[],
productId: string[],
) => {
return subscriptions.some(
(subscription) =>
subscription.status === SubscriptionStatus.ACTIVE && productId.includes(subscription.planId),
);
};
export const subscriptionsContainActiveEnterprisePlan = (
subscriptions?: Subscription[],

View File

@ -0,0 +1,15 @@
import { WEBAPP_BASE_URL } from '../constants/app';
export const formatUserProfilePath = (
profileUrl: string,
options: { excludeBaseUrl?: boolean } = {},
) => {
return `${!options?.excludeBaseUrl ? WEBAPP_BASE_URL : ''}/p/${profileUrl}`;
};
export const formatTeamProfilePath = (
profileUrl: string,
options: { excludeBaseUrl?: boolean } = {},
) => {
return `${!options?.excludeBaseUrl ? WEBAPP_BASE_URL : ''}/p/${profileUrl}`;
};

View File

@ -50,7 +50,7 @@ model User {
twoFactorBackupCodes String?
url String? @unique
userProfile UserProfile?
profile UserProfile?
VerificationToken VerificationToken[]
ApiToken ApiToken[]
Template Template[]
@ -63,10 +63,21 @@ model User {
}
model UserProfile {
id Int @id
bio String?
id String @id @default(cuid())
enabled Boolean @default(false)
userId Int @unique
bio String?
User User? @relation(fields: [id], references: [id], onDelete: Cascade)
User User? @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model TeamProfile {
id String @id @default(cuid())
enabled Boolean @default(false)
teamId Int @unique
bio String?
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
}
enum UserSecurityAuditLogType {
@ -475,6 +486,7 @@ model Team {
emailVerification TeamEmailVerification?
transferVerification TeamTransferVerification?
profile TeamProfile?
owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
subscription Subscription?
@ -580,6 +592,8 @@ model Template {
templateDocumentDataId String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
publicTitle String @default("")
publicDescription String @default("")
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)

View File

@ -88,9 +88,9 @@ export const profileRouter = router({
.input(ZUpdatePublicProfileMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { url } = input;
const { url, bio, enabled } = input;
if (IS_BILLING_ENABLED() && url.length < 6) {
if (IS_BILLING_ENABLED() && url !== undefined && url.length < 6) {
const subscriptions = await getSubscriptionsByUserId({
userId: ctx.user.id,
}).then((subscriptions) =>
@ -107,7 +107,11 @@ export const profileRouter = router({
const user = await updatePublicProfile({
userId: ctx.user.id,
url,
data: {
url,
bio,
enabled,
},
});
return { success: true, url: user.url };

View File

@ -2,6 +2,8 @@ import { z } from 'zod';
import { ZCurrentPasswordSchema, ZPasswordSchema } from '../auth-router/schema';
export const MAX_PROFILE_BIO_LENGTH = 256;
export const ZFindUserSecurityAuditLogsSchema = z.object({
page: z.number().optional(),
perPage: z.number().optional(),
@ -17,6 +19,8 @@ export const ZUpdateProfileMutationSchema = z.object({
});
export const ZUpdatePublicProfileMutationSchema = z.object({
bio: z.string().max(MAX_PROFILE_BIO_LENGTH).optional(),
enabled: z.boolean().optional(),
url: z
.string()
.trim()
@ -24,7 +28,8 @@ export const ZUpdatePublicProfileMutationSchema = z.object({
.min(1, { message: 'Please enter a valid username.' })
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
}),
})
.optional(),
});
export const ZUpdatePasswordMutationSchema = z.object({

View File

@ -1,5 +1,7 @@
import { TRPCError } from '@trpc/server';
import { getTeamPrices } from '@documenso/ee/server-only/stripe/get-team-prices';
import { AppError } from '@documenso/lib/errors/app-error';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { acceptTeamInvitation } from '@documenso/lib/server-only/team/accept-team-invitation';
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { createTeamBillingPortal } from '@documenso/lib/server-only/team/create-team-billing-portal';
@ -30,6 +32,7 @@ import { resendTeamMemberInvitation } from '@documenso/lib/server-only/team/rese
import { updateTeam } from '@documenso/lib/server-only/team/update-team';
import { updateTeamEmail } from '@documenso/lib/server-only/team/update-team-email';
import { updateTeamMember } from '@documenso/lib/server-only/team/update-team-member';
import { updateTeamPublicProfile } from '@documenso/lib/server-only/team/update-team-public-profile';
import { authenticatedProcedure, router } from '../trpc';
import {
@ -60,6 +63,7 @@ import {
ZUpdateTeamEmailMutationSchema,
ZUpdateTeamMemberMutationSchema,
ZUpdateTeamMutationSchema,
ZUpdateTeamPublicProfileMutationSchema,
} from './schema';
export const teamRouter = router({
@ -459,6 +463,39 @@ export const teamRouter = router({
}
}),
updateTeamPublicProfile: authenticatedProcedure
.input(ZUpdateTeamPublicProfileMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { teamId, bio, enabled } = input;
const team = await updateTeamPublicProfile({
userId: ctx.user.id,
teamId,
data: {
bio,
enabled,
},
});
return { success: true, url: team.url };
} catch (err) {
console.error(err);
const error = AppError.parseError(err);
if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
throw AppError.parseErrorToTRPCError(error);
}
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'We were unable to update your public profile. Please review the information you provided and try again.',
});
}
}),
requestTeamOwnershipTransfer: authenticatedProcedure
.input(ZRequestTeamOwnerhsipTransferMutationSchema)
.mutation(async ({ input, ctx }) => {

View File

@ -3,6 +3,8 @@ import { z } from 'zod';
import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams';
import { TeamMemberRole } from '@documenso/prisma/client';
import { ZUpdatePublicProfileMutationSchema } from '../profile-router/schema';
// Consider refactoring to use ZBaseTableSearchParamsSchema.
const GenericFindQuerySchema = z.object({
term: z.string().optional(),
@ -162,6 +164,13 @@ export const ZUpdateTeamMemberMutationSchema = z.object({
}),
});
export const ZUpdateTeamPublicProfileMutationSchema = ZUpdatePublicProfileMutationSchema.pick({
bio: true,
enabled: true,
}).extend({
teamId: z.number(),
});
export const ZRequestTeamOwnerhsipTransferMutationSchema = z.object({
teamId: z.number(),
newOwnerUserId: z.number(),

View File

@ -10,6 +10,7 @@ import { createTemplateDirectLink } from '@documenso/lib/server-only/template/cr
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { deleteTemplateDirectLink } from '@documenso/lib/server-only/template/delete-template-direct-link';
import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link';
import { updateTemplateSettings } from '@documenso/lib/server-only/template/update-template-settings';
@ -25,6 +26,7 @@ import {
ZDeleteTemplateDirectLinkMutationSchema,
ZDeleteTemplateMutationSchema,
ZDuplicateTemplateMutationSchema,
ZFindTemplatesQuerySchema,
ZGetTemplateWithDetailsByIdQuerySchema,
ZToggleTemplateDirectLinkMutationSchema,
ZUpdateTemplateSettingsMutationSchema,
@ -214,6 +216,21 @@ export const templateRouter = router({
}
}),
findTemplates: authenticatedProcedure
.input(ZFindTemplatesQuerySchema)
.query(async ({ input, ctx }) => {
try {
return await findTemplates({
userId: ctx.user.id,
...input,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
createTemplateDirectLink: authenticatedProcedure
.input(ZCreateTemplateDirectLinkMutationSchema)
.mutation(async ({ input, ctx }) => {

View File

@ -5,6 +5,8 @@ import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { TemplateType } from '@documenso/prisma/client';
import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema';
@ -62,6 +64,9 @@ export const ZDeleteTemplateMutationSchema = z.object({
id: z.number().min(1),
});
export const MAX_TEMPLATE_PUBLIC_TITLE_LENGTH = 50;
export const MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH = 256;
export const ZUpdateTemplateSettingsMutationSchema = z.object({
templateId: z.number(),
teamId: z.number().min(1).optional(),
@ -69,19 +74,34 @@ export const ZUpdateTemplateSettingsMutationSchema = z.object({
title: z.string().min(1).optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(),
}),
meta: z.object({
subject: z.string(),
message: z.string(),
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: z
publicTitle: z.string().trim().min(1).max(MAX_TEMPLATE_PUBLIC_TITLE_LENGTH).optional(),
publicDescription: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
.trim()
.min(1)
.max(MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH)
.optional(),
type: z.nativeEnum(TemplateType).optional(),
}),
meta: z
.object({
subject: z.string(),
message: z.string(),
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
})
.optional(),
});
export const ZFindTemplatesQuerySchema = ZBaseTableSearchParamsSchema.extend({
teamId: z.number().optional(),
type: z.nativeEnum(TemplateType).optional(),
});
export const ZGetTemplateWithDetailsByIdQuerySchema = z.object({

View File

@ -2,6 +2,7 @@
"extends": "./base.json",
"compilerOptions": {
"noEmit": true,
"allowUnreachableCode": true
},
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts", "**/*.json"],
"exclude": ["dist", "build", "node_modules"]

View File

@ -9,8 +9,11 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<textarea
className={cn(
'border-input ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-20 w-full rounded-md border bg-transparent px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'border-input ring-offset-background placeholder:text-muted-foreground/40 focus-visible:ring-ring flex h-20 w-full rounded-md border bg-transparent px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
{
'ring-2 !ring-red-500 transition-all': props['aria-invalid'],
},
)}
ref={ref}
{...props}