Merge branch 'main' into power-signer

This commit is contained in:
Lucas Smith
2025-02-28 21:11:35 +11:00
committed by GitHub
30 changed files with 609 additions and 105 deletions

View File

@ -4,15 +4,14 @@ import { stripe } from '@documenso/lib/server-only/stripe';
type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
const planTypes = typeof plan === 'string' ? [plan] : plan;
const planTypes: string[] = typeof plan === 'string' ? [plan] : plan;
const query = planTypes.map((planType) => `metadata['plan']:'${planType}'`).join(' OR ');
const { data: prices } = await stripe.prices.search({
query,
const prices = await stripe.prices.list({
expand: ['data.product'],
limit: 100,
});
return prices.filter((price) => price.type === 'recurring');
return prices.data.filter(
(price) => price.type === 'recurring' && planTypes.includes(price.metadata.plan),
);
};

View File

@ -0,0 +1,56 @@
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import type { Subscription } from '@documenso/prisma/client';
import { getCommunityPlanPriceIds } from '../stripe/get-community-plan-prices';
export type IsCommunityPlanOptions = {
userId: number;
teamId?: number;
};
/**
* Whether the user or team is on the community plan.
*/
export const isCommunityPlan = async ({
userId,
teamId,
}: IsCommunityPlanOptions): Promise<boolean> => {
let subscriptions: Subscription[] = [];
if (teamId) {
subscriptions = await prisma.team
.findFirstOrThrow({
where: {
id: teamId,
},
select: {
owner: {
include: {
subscriptions: true,
},
},
},
})
.then((team) => team.owner.subscriptions);
} else {
subscriptions = await prisma.user
.findFirstOrThrow({
where: {
id: userId,
},
select: {
subscriptions: true,
},
})
.then((user) => user.subscriptions);
}
if (subscriptions.length === 0) {
return false;
}
const communityPlanPriceIds = await getCommunityPlanPriceIds();
return subscriptionsContainsActivePlan(subscriptions, communityPlanPriceIds);
};

View File

@ -12,6 +12,7 @@ import {
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import type { TRecipientActionAuth } from '../../types/document-auth';
import {
@ -72,6 +73,13 @@ export const completeDocumentWithToken = async ({
throw new Error(`Recipient ${recipient.id} has already signed`);
}
if (recipient.signingStatus === SigningStatus.REJECTED) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Recipient has already rejected the document',
statusCode: 400,
});
}
if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });

View File

@ -88,6 +88,7 @@ export const findDocuments = async ({
const searchFilter: Prisma.DocumentWhereInput = {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ externalId: { contains: query, mode: 'insensitive' } },
{ recipients: { some: { name: { contains: query, mode: 'insensitive' } } } },
{ recipients: { some: { email: { contains: query, mode: 'insensitive' } } } },
],

View File

@ -34,6 +34,14 @@ export const searchDocumentsWithKeyword = async ({
userId: userId,
deletedAt: null,
},
{
externalId: {
contains: query,
mode: 'insensitive',
},
userId: userId,
deletedAt: null,
},
{
recipients: {
some: {
@ -88,6 +96,23 @@ export const searchDocumentsWithKeyword = async ({
},
deletedAt: null,
},
{
externalId: {
contains: query,
mode: 'insensitive',
},
teamId: {
not: null,
},
team: {
members: {
some: {
userId: userId,
},
},
},
deletedAt: null,
},
],
},
include: {

View File

@ -0,0 +1,18 @@
/*
Warnings:
- You are about to drop the column `expires` on the `Session` table. All the data in the column will be lost.
- Added the required column `expiresAt` to the `Session` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `Session` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Account" ADD COLUMN "password" TEXT;
-- AlterTable
ALTER TABLE "Session" DROP COLUMN "expires",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "expiresAt" TIMESTAMP(3) NOT NULL,
ADD COLUMN "ipAddress" TEXT,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
ADD COLUMN "userAgent" TEXT;

View File

@ -270,18 +270,25 @@ model Account {
scope String?
id_token String? @db.Text
session_state String?
password String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
id String @id @default(cuid())
sessionToken String @unique
userId Int
expires DateTime
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
ipAddress String?
userAgent String?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum DocumentStatus {

View File

@ -5,6 +5,18 @@ import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..';
import { DocumentDataType, DocumentSource, Role, TeamMemberRole } from '../client';
import { seedPendingDocument } from './documents';
import { seedDirectTemplate, seedTemplate } from './templates';
const createDocumentData = async ({ documentData }: { documentData: string }) => {
return prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: documentData,
initialData: documentData,
},
});
};
export const seedDatabase = async () => {
const examplePdf = fs
@ -39,35 +51,80 @@ export const seedDatabase = async () => {
update: {},
});
const examplePdfData = await prisma.documentData.upsert({
where: {
id: 'clmn0kv5k0000pe04vcqg5zla',
},
create: {
id: 'clmn0kv5k0000pe04vcqg5zla',
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
update: {},
});
for (let i = 1; i <= 4; i++) {
const documentData = await createDocumentData({ documentData: examplePdf });
await prisma.document.create({
data: {
source: DocumentSource.DOCUMENT,
title: 'Example Document',
documentDataId: examplePdfData.id,
userId: exampleUser.id,
recipients: {
create: {
name: String(adminUser.name),
email: adminUser.email,
token: Math.random().toString(36).slice(2, 9),
await prisma.document.create({
data: {
source: DocumentSource.DOCUMENT,
title: `Example Document ${i}`,
documentDataId: documentData.id,
userId: exampleUser.id,
recipients: {
create: {
name: String(adminUser.name),
email: adminUser.email,
token: Math.random().toString(36).slice(2, 9),
},
},
},
});
}
for (let i = 1; i <= 4; i++) {
const documentData = await createDocumentData({ documentData: examplePdf });
await prisma.document.create({
data: {
source: DocumentSource.DOCUMENT,
title: `Document ${i}`,
documentDataId: documentData.id,
userId: adminUser.id,
recipients: {
create: {
name: String(exampleUser.name),
email: exampleUser.email,
token: Math.random().toString(36).slice(2, 9),
},
},
},
});
}
await seedPendingDocument(exampleUser, [adminUser], {
key: 'example-pending',
createDocumentOptions: {
title: 'Pending Document',
},
});
await seedPendingDocument(adminUser, [exampleUser], {
key: 'admin-pending',
createDocumentOptions: {
title: 'Pending Document',
},
});
await Promise.all([
seedTemplate({
title: 'Template 1',
userId: exampleUser.id,
}),
seedDirectTemplate({
title: 'Direct Template 1',
userId: exampleUser.id,
}),
seedTemplate({
title: 'Template 1',
userId: adminUser.id,
}),
seedDirectTemplate({
title: 'Direct Template 1',
userId: adminUser.id,
}),
]);
const testUsers = [
'test@documenso.com',
'test2@documenso.com',

View File

@ -12,6 +12,7 @@ import { match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
import { FieldType } from '@documenso/prisma/client';
import { useSignerColors } from '../../lib/signer-colors';
import { cn } from '../../lib/utils';
@ -185,11 +186,35 @@ export const FieldItem = ({
() => hasFieldMetaValues('CHECKBOX', field.fieldMeta, ZCheckboxFieldMeta),
[field.fieldMeta],
);
const radioHasValues = useMemo(
() => hasFieldMetaValues('RADIO', field.fieldMeta, ZRadioFieldMeta),
[field.fieldMeta],
);
const hasCheckedValues = (fieldMeta: TFieldMetaSchema, type: FieldType) => {
if (!fieldMeta || (type !== FieldType.RADIO && type !== FieldType.CHECKBOX)) {
return false;
}
if (type === FieldType.RADIO) {
const parsed = ZRadioFieldMeta.parse(fieldMeta);
return parsed.values?.some((value) => value.checked) ?? false;
}
if (type === FieldType.CHECKBOX) {
const parsed = ZCheckboxFieldMeta.parse(fieldMeta);
return parsed.values?.some((value) => value.checked) ?? false;
}
return false;
};
const fieldHasCheckedValues = useMemo(
() => hasCheckedValues(field.fieldMeta, field.type),
[field.fieldMeta, field.type],
);
const fixedSize = checkBoxHasValues || radioHasValues;
return createPortal(
@ -229,6 +254,21 @@ export const FieldItem = ({
onMove?.(d.node);
}}
>
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
field.fieldMeta?.label && (
<div
className={cn(
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
{
'bg-foreground/5 border-primary border': !fieldHasCheckedValues,
'bg-documenso-200 border-primary border': fieldHasCheckedValues,
},
)}
>
{field.fieldMeta.label}
</div>
)}
<div
className={cn(
'relative flex h-full w-full items-center justify-center bg-white',

View File

@ -126,6 +126,18 @@ export const CheckboxFieldAdvancedSettings = ({
return (
<div className="flex flex-col gap-4">
<div className="mb-2">
<Label>
<Trans>Label</Trans>
</Label>
<Input
id="label"
className="bg-background mt-2"
placeholder={_(msg`Field label`)}
value={fieldState.label}
onChange={(e) => handleFieldChange('label', e.target.value)}
/>
</div>
<div className="flex flex-row items-center gap-x-4">
<div className="flex w-2/3 flex-col">
<Label>

View File

@ -2,7 +2,8 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/macro';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { ChevronDown, ChevronUp, Trash } from 'lucide-react';
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
@ -27,6 +28,8 @@ export const RadioFieldAdvancedSettings = ({
handleFieldChange,
handleErrors,
}: RadioFieldAdvancedSettingsProps) => {
const { _ } = useLingui();
const [showValidation, setShowValidation] = useState(false);
const [values, setValues] = useState(
fieldState.values ?? [{ id: 1, checked: false, value: 'Default value' }],
@ -102,6 +105,18 @@ export const RadioFieldAdvancedSettings = ({
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<div>
<Label>
<Trans>Label</Trans>
</Label>
<Input
id="label"
className="bg-background mt-2"
placeholder={_(msg`Field label`)}
value={fieldState.label}
onChange={(e) => handleFieldChange('label', e.target.value)}
/>
</div>
<div className="flex flex-row items-center gap-2">
<Switch
className="bg-background"