mirror of
https://github.com/documenso/documenso.git
synced 2025-11-16 09:41:35 +10:00
feat: add public profiles (#1180)
## Description Add public profiles ## Changes - Add profiles settings page for users and teams - Add profiles page `/p/<url>` ## Not completed - Pending tests - UI changes to promote public profiles (sign up, etc)
This commit is contained in:
@ -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;
|
||||
|
||||
@ -49,6 +49,7 @@
|
||||
"playwright": "1.43.0",
|
||||
"react": "18.2.0",
|
||||
"remeda": "^1.27.1",
|
||||
"sharp": "^0.33.1",
|
||||
"stripe": "^12.7.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "^3.22.4"
|
||||
@ -58,4 +59,4 @@
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/pg": "^8.11.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
26
packages/lib/server-only/profile/get-avatar-image.ts
Normal file
26
packages/lib/server-only/profile/get-avatar-image.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetAvatarImageOptions = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const getAvatarImage = async ({ id }: GetAvatarImageOptions) => {
|
||||
const avatarImage = await prisma.avatarImage.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!avatarImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(avatarImage.bytes, 'base64');
|
||||
|
||||
return {
|
||||
contentType: 'image/jpeg',
|
||||
content: await sharp(bytes).toFormat('jpeg').toBuffer(),
|
||||
};
|
||||
};
|
||||
181
packages/lib/server-only/profile/get-public-profile-by-url.ts
Normal file
181
packages/lib/server-only/profile/get-public-profile-by-url.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { getCommunityPlanPriceIds } from '@documenso/ee/server-only/stripe/get-community-plan-prices';
|
||||
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 { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export type GetPublicProfileByUrlOptions = {
|
||||
profileUrl: string;
|
||||
};
|
||||
|
||||
type PublicDirectLinkTemplate = Template & {
|
||||
type: 'PUBLIC';
|
||||
directLink: TemplateDirectLink & {
|
||||
enabled: true;
|
||||
};
|
||||
};
|
||||
|
||||
type BaseResponse = {
|
||||
url: string;
|
||||
name: string;
|
||||
avatarImageId?: string | null;
|
||||
badge?: {
|
||||
type: 'Premium' | 'EarlySupporter';
|
||||
since: Date;
|
||||
};
|
||||
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 teamMembers are used to calculate the badges.
|
||||
Subscription: {
|
||||
where: {
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
},
|
||||
},
|
||||
teamMembers: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
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.teamMembers[0]) {
|
||||
badge = {
|
||||
type: 'Premium',
|
||||
since: user.teamMembers[0]['createdAt'],
|
||||
};
|
||||
}
|
||||
|
||||
if (IS_BILLING_ENABLED()) {
|
||||
const earlyAdopterPriceIds = await getCommunityPlanPriceIds();
|
||||
|
||||
const activeEarlyAdopterSub = user.Subscription.find(
|
||||
(subscription) =>
|
||||
subscription.status === SubscriptionStatus.ACTIVE &&
|
||||
earlyAdopterPriceIds.includes(subscription.priceId),
|
||||
);
|
||||
|
||||
if (activeEarlyAdopterSub) {
|
||||
badge = {
|
||||
type: 'EarlySupporter',
|
||||
since: activeEarlyAdopterSub.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'User',
|
||||
badge,
|
||||
profile: user.profile,
|
||||
url: profileUrl,
|
||||
avatarImageId: user.avatarImageId,
|
||||
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: {
|
||||
type: 'Premium',
|
||||
since: team.createdAt,
|
||||
},
|
||||
profile: team.profile,
|
||||
url: profileUrl,
|
||||
avatarImageId: team.avatarImageId,
|
||||
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');
|
||||
};
|
||||
106
packages/lib/server-only/profile/set-avatar-image.ts
Normal file
106
packages/lib/server-only/profile/set-avatar-image.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
|
||||
export type SetAvatarImageOptions = {
|
||||
userId: number;
|
||||
teamId?: number | null;
|
||||
bytes?: string | null;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const setAvatarImage = async ({
|
||||
userId,
|
||||
teamId,
|
||||
bytes,
|
||||
requestMetadata,
|
||||
}: SetAvatarImageOptions) => {
|
||||
let oldAvatarImageId: string | null = null;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
avatarImage: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
oldAvatarImageId = user.avatarImageId;
|
||||
|
||||
if (teamId) {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new Error('Team not found');
|
||||
}
|
||||
|
||||
oldAvatarImageId = team.avatarImageId;
|
||||
}
|
||||
|
||||
if (oldAvatarImageId) {
|
||||
await prisma.avatarImage.delete({
|
||||
where: {
|
||||
id: oldAvatarImageId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let newAvatarImageId: string | null = null;
|
||||
|
||||
if (bytes) {
|
||||
const optimisedBytes = await sharp(Buffer.from(bytes, 'base64'))
|
||||
.resize(512, 512)
|
||||
.toFormat('jpeg', { quality: 75 })
|
||||
.toBuffer();
|
||||
|
||||
const avatarImage = await prisma.avatarImage.create({
|
||||
data: {
|
||||
bytes: optimisedBytes.toString('base64'),
|
||||
},
|
||||
});
|
||||
|
||||
newAvatarImageId = avatarImage.id;
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
await prisma.team.update({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
data: {
|
||||
avatarImageId: newAvatarImageId,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Audit Logs
|
||||
} else {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
avatarImageId: newAvatarImageId,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Audit Logs
|
||||
}
|
||||
|
||||
return newAvatarImageId;
|
||||
};
|
||||
@ -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.');
|
||||
}
|
||||
|
||||
63
packages/lib/server-only/team/get-team-public-profile.ts
Normal file
63
packages/lib/server-only/team/get-team-public-profile.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
38
packages/lib/server-only/team/update-team-public-profile.ts
Normal file
38
packages/lib/server-only/team/update-team-public-profile.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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) {
|
||||
|
||||
@ -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: {
|
||||
|
||||
55
packages/lib/server-only/user/get-user-public-profile.ts
Normal file
55
packages/lib/server-only/user/get-user-public-profile.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@ -4,28 +4,61 @@ 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, 'The profile username is already taken');
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.user.update({
|
||||
@ -34,16 +67,21 @@ export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOp
|
||||
},
|
||||
data: {
|
||||
url,
|
||||
userProfile: {
|
||||
profile: {
|
||||
upsert: {
|
||||
create: {
|
||||
bio: '',
|
||||
bio,
|
||||
enabled,
|
||||
},
|
||||
update: {
|
||||
bio: '',
|
||||
bio,
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
profile: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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[],
|
||||
|
||||
15
packages/lib/utils/public-profiles.ts
Normal file
15
packages/lib/utils/public-profiles.ts
Normal 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}`;
|
||||
};
|
||||
@ -0,0 +1,65 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `UserProfile` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- A unique constraint covering the columns `[userId]` on the table `UserProfile` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `userId` to the `UserProfile` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
|
||||
-- Custom (Drop duplicate)
|
||||
UPDATE "User"
|
||||
SET "url" = NULL
|
||||
WHERE "User"."url" IN (
|
||||
SELECT "UserTeamUrl"."url"
|
||||
FROM (
|
||||
SELECT "url"
|
||||
FROM "User"
|
||||
WHERE "User"."url" IS NOT null
|
||||
UNION ALL
|
||||
SELECT "url"
|
||||
FROM "Team"
|
||||
WHERE "Team"."url" IS NOT null
|
||||
) as "UserTeamUrl"
|
||||
GROUP BY "UserTeamUrl"."url"
|
||||
HAVING COUNT("UserTeamUrl"."url") > 1
|
||||
);
|
||||
|
||||
-- Custom (Drop existing profiles since they're not used)
|
||||
DELETE FROM "UserProfile";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "UserProfile" DROP CONSTRAINT "UserProfile_id_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Template" ADD COLUMN "publicDescription" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "publicTitle" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "UserProfile" DROP CONSTRAINT "UserProfile_pkey",
|
||||
ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "userId" INTEGER NOT NULL,
|
||||
ALTER COLUMN "id" SET DATA TYPE TEXT,
|
||||
ADD CONSTRAINT "UserProfile_pkey" PRIMARY KEY ("id");
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamProfile" (
|
||||
"id" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"teamId" INTEGER NOT NULL,
|
||||
"bio" TEXT,
|
||||
|
||||
CONSTRAINT "TeamProfile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TeamProfile_teamId_key" ON "TeamProfile"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UserProfile_userId_key" ON "UserProfile"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserProfile" ADD CONSTRAINT "UserProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamProfile" ADD CONSTRAINT "TeamProfile_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -0,0 +1,19 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Team" ADD COLUMN "avatarImageId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "avatarImageId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AvatarImage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bytes" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "AvatarImage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_avatarImageId_fkey" FOREIGN KEY ("avatarImageId") REFERENCES "AvatarImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Team" ADD CONSTRAINT "Team_avatarImageId_fkey" FOREIGN KEY ("avatarImageId") REFERENCES "AvatarImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -24,19 +24,21 @@ enum Role {
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String?
|
||||
customerId String? @unique
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
password String?
|
||||
source String?
|
||||
signature String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
lastSignedIn DateTime @default(now())
|
||||
roles Role[] @default([USER])
|
||||
identityProvider IdentityProvider @default(DOCUMENSO)
|
||||
id Int @id @default(autoincrement())
|
||||
name String?
|
||||
customerId String? @unique
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
password String?
|
||||
source String?
|
||||
signature String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
lastSignedIn DateTime @default(now())
|
||||
roles Role[] @default([USER])
|
||||
identityProvider IdentityProvider @default(DOCUMENSO)
|
||||
avatarImageId String?
|
||||
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
Document Document[]
|
||||
@ -50,7 +52,7 @@ model User {
|
||||
twoFactorBackupCodes String?
|
||||
url String? @unique
|
||||
|
||||
userProfile UserProfile?
|
||||
profile UserProfile?
|
||||
VerificationToken VerificationToken[]
|
||||
ApiToken ApiToken[]
|
||||
Template Template[]
|
||||
@ -58,15 +60,27 @@ model User {
|
||||
Webhooks Webhook[]
|
||||
siteSettings SiteSettings[]
|
||||
passkeys Passkey[]
|
||||
avatarImage AvatarImage? @relation(fields: [avatarImageId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -463,18 +477,22 @@ enum TeamMemberInviteStatus {
|
||||
}
|
||||
|
||||
model Team {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
url String @unique
|
||||
createdAt DateTime @default(now())
|
||||
customerId String? @unique
|
||||
ownerUserId Int
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
url String @unique
|
||||
createdAt DateTime @default(now())
|
||||
avatarImageId String?
|
||||
customerId String? @unique
|
||||
ownerUserId Int
|
||||
|
||||
members TeamMember[]
|
||||
invites TeamMemberInvite[]
|
||||
teamEmail TeamEmail?
|
||||
emailVerification TeamEmailVerification?
|
||||
transferVerification TeamTransferVerification?
|
||||
avatarImage AvatarImage? @relation(fields: [avatarImageId], references: [id], onDelete: SetNull)
|
||||
|
||||
profile TeamProfile?
|
||||
owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
|
||||
subscription Subscription?
|
||||
|
||||
@ -580,6 +598,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)
|
||||
@ -662,3 +682,11 @@ model BackgroundJobTask {
|
||||
jobId String
|
||||
backgroundJob BackgroundJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model AvatarImage {
|
||||
id String @id @default(cuid())
|
||||
bytes String
|
||||
|
||||
team Team[]
|
||||
user User[]
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
||||
import { setAvatarImage } from '@documenso/lib/server-only/profile/set-avatar-image';
|
||||
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
||||
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
|
||||
import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs';
|
||||
@ -22,6 +23,7 @@ import {
|
||||
ZForgotPasswordFormSchema,
|
||||
ZResetPasswordFormSchema,
|
||||
ZRetrieveUserByIdQuerySchema,
|
||||
ZSetProfileImageMutationSchema,
|
||||
ZUpdatePasswordMutationSchema,
|
||||
ZUpdateProfileMutationSchema,
|
||||
ZUpdatePublicProfileMutationSchema,
|
||||
@ -88,9 +90,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 +109,11 @@ export const profileRouter = router({
|
||||
|
||||
const user = await updatePublicProfile({
|
||||
userId: ctx.user.id,
|
||||
url,
|
||||
data: {
|
||||
url,
|
||||
bio,
|
||||
enabled,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, url: user.url };
|
||||
@ -242,4 +248,32 @@ export const profileRouter = router({
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
setProfileImage: authenticatedProcedure
|
||||
.input(ZSetProfileImageMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { bytes, teamId } = input;
|
||||
|
||||
return await setAvatarImage({
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
bytes,
|
||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
let message = 'We were unable to update your profile image. Please try again.';
|
||||
|
||||
if (err instanceof Error) {
|
||||
message = err.message;
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@ -2,21 +2,36 @@ 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(),
|
||||
});
|
||||
|
||||
export type TFindUserSecurityAuditLogsSchema = z.infer<typeof ZFindUserSecurityAuditLogsSchema>;
|
||||
|
||||
export const ZRetrieveUserByIdQuerySchema = z.object({
|
||||
id: z.number().min(1),
|
||||
});
|
||||
|
||||
export type TRetrieveUserByIdQuerySchema = z.infer<typeof ZRetrieveUserByIdQuerySchema>;
|
||||
|
||||
export const ZUpdateProfileMutationSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
|
||||
|
||||
export const ZUpdatePublicProfileMutationSchema = z.object({
|
||||
bio: z
|
||||
.string()
|
||||
.max(MAX_PROFILE_BIO_LENGTH, {
|
||||
message: `Bio must be shorter than ${MAX_PROFILE_BIO_LENGTH + 1} characters`,
|
||||
})
|
||||
.optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
url: z
|
||||
.string()
|
||||
.trim()
|
||||
@ -24,31 +39,41 @@ 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 type TUpdatePublicProfileMutationSchema = z.infer<typeof ZUpdatePublicProfileMutationSchema>;
|
||||
|
||||
export const ZUpdatePasswordMutationSchema = z.object({
|
||||
currentPassword: ZCurrentPasswordSchema,
|
||||
password: ZPasswordSchema,
|
||||
});
|
||||
|
||||
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
|
||||
|
||||
export const ZForgotPasswordFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
});
|
||||
|
||||
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
|
||||
|
||||
export const ZResetPasswordFormSchema = z.object({
|
||||
password: ZPasswordSchema,
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;
|
||||
|
||||
export const ZConfirmEmailMutationSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
});
|
||||
|
||||
export type TFindUserSecurityAuditLogsSchema = z.infer<typeof ZFindUserSecurityAuditLogsSchema>;
|
||||
export type TRetrieveUserByIdQuerySchema = z.infer<typeof ZRetrieveUserByIdQuerySchema>;
|
||||
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
|
||||
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
|
||||
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
|
||||
export type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;
|
||||
export type TConfirmEmailMutationSchema = z.infer<typeof ZConfirmEmailMutationSchema>;
|
||||
|
||||
export const ZSetProfileImageMutationSchema = z.object({
|
||||
bytes: z.string().nullish(),
|
||||
teamId: z.number().min(1).nullish(),
|
||||
});
|
||||
|
||||
export type TSetProfileImageMutationSchema = z.infer<typeof ZSetProfileImageMutationSchema>;
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -63,6 +65,9 @@ export const ZDeleteTemplateMutationSchema = z.object({
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
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(),
|
||||
@ -70,19 +75,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({
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"allowUnreachableCode": true
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts", "**/*.json"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
|
||||
@ -50,6 +50,7 @@ AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
type AvatarWithTextProps = {
|
||||
avatarClass?: string;
|
||||
avatarSrc?: string | null;
|
||||
avatarFallback: string;
|
||||
className?: string;
|
||||
primaryText: React.ReactNode;
|
||||
@ -61,6 +62,7 @@ type AvatarWithTextProps = {
|
||||
|
||||
const AvatarWithText = ({
|
||||
avatarClass,
|
||||
avatarSrc,
|
||||
avatarFallback,
|
||||
className,
|
||||
primaryText,
|
||||
@ -72,6 +74,7 @@ const AvatarWithText = ({
|
||||
<Avatar
|
||||
className={cn('dark:border-border h-10 w-10 border-2 border-solid border-white', avatarClass)}
|
||||
>
|
||||
{avatarSrc && <AvatarImage src={avatarSrc} />}
|
||||
<AvatarFallback className="text-xs text-gray-400">{avatarFallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
Reference in New Issue
Block a user