Add electronic signature
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -1,147 +0,0 @@
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button, Card, Field, Logo, Spinner } from "@probo/ui";
|
||||
import { clsx } from "clsx";
|
||||
import { use, useEffect } from "react";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { useWindowSize } from "usehooks-ts";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToast";
|
||||
import { Viewer } from "#/providers/Viewer";
|
||||
|
||||
import { PDFPreview } from "./PDFPreview";
|
||||
|
||||
const signMutation = graphql`
|
||||
mutation NDADialogSignMutation($input: AcceptNonDisclosureAgreementInput!) {
|
||||
acceptNonDisclosureAgreement(input: $input) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const schema = z.object({
|
||||
fullName: z.string(),
|
||||
});
|
||||
|
||||
export function NDADialog({
|
||||
organizationName,
|
||||
url,
|
||||
fileName,
|
||||
}: {
|
||||
organizationName: string;
|
||||
url?: string | null;
|
||||
fileName?: string | null;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
useEffect(() => {
|
||||
document.body.style.setProperty("overflow", "hidden");
|
||||
return () => {
|
||||
document.body.style.removeProperty("overflow");
|
||||
};
|
||||
}, []);
|
||||
const { width } = useWindowSize();
|
||||
const isMobile = width < 1100;
|
||||
const isDesktop = !isMobile;
|
||||
const viewer = use(Viewer);
|
||||
|
||||
const {
|
||||
handleSubmit: handleSubmitWrapper,
|
||||
register,
|
||||
formState,
|
||||
} = useFormWithSchema(schema, {
|
||||
defaultValues: {
|
||||
fullName: viewer?.fullName,
|
||||
},
|
||||
});
|
||||
|
||||
const [commitSigning, isSigning] = useMutationWithToasts(signMutation, {
|
||||
onSuccess: () => {
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = handleSubmitWrapper(async ({ fullName }) => {
|
||||
await commitSigning({
|
||||
variables: {
|
||||
input: {
|
||||
fullName,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-level-2 z-100 flex flex-col lg:h-screen">
|
||||
<header className="flex items-center h-12 justify-between border-b border-border-solid px-4 flex-none">
|
||||
<Logo />
|
||||
</header>
|
||||
<div className="grid lg:grid-cols-2 min-h-0 h-full">
|
||||
<div className="max-w-[440px] mx-auto py-20">
|
||||
<h1 className="text-2xl font-semibold mb-4">
|
||||
{__("Review & Sign NDA")}
|
||||
</h1>
|
||||
<p className="text-txt-secondary">
|
||||
{sprintf(
|
||||
__(
|
||||
"Access to %s compliance page documents requires signing a Non-Disclosure Agreement (NDA). Please review the agreement below. Once signed, you’ll receive immediate access to the requested documents.",
|
||||
),
|
||||
organizationName,
|
||||
)}
|
||||
</p>
|
||||
{isMobile && url && (
|
||||
<Card className="flex justify-between py-3 px-4 text-sm items-center my-6">
|
||||
{fileName}
|
||||
<Button variant="secondary" asChild>
|
||||
<a target="_blank" rel="noopener noreferrer" href={url}>
|
||||
{__("View document")}
|
||||
</a>
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
<form onSubmit={e => void handleSubmit(e)}>
|
||||
<div className="mt-4">
|
||||
<Field
|
||||
required
|
||||
label={__("Full name")}
|
||||
placeholder="John Doe"
|
||||
{...register("fullName")}
|
||||
type="text"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-10 w-full my-8"
|
||||
disabled={formState.isSubmitting || !formState.isValid}
|
||||
icon={isSigning ? Spinner : undefined}
|
||||
>
|
||||
{__("Review & Sign")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<p className="text-xs text-txt-secondary">
|
||||
{__(
|
||||
"By clicking Review & Sign, you agree to the terms of this NDA. If you have questions about the NDA, please contact security@probo.com.",
|
||||
)}
|
||||
</p>
|
||||
<a
|
||||
href="https://www.getprobo.com/"
|
||||
className={clsx(
|
||||
"flex gap-1 text-sm font-medium text-txt-tertiary items-center w-max mx-auto",
|
||||
isMobile ? "mt-15" : "mt-30",
|
||||
)}
|
||||
>
|
||||
Powered by
|
||||
{" "}
|
||||
<Logo withPicto className="h-6" />
|
||||
</a>
|
||||
</div>
|
||||
{isDesktop && (
|
||||
<div className="bg-subtle h-full border-l border-border-solid min-h-0">
|
||||
{url && <PDFPreview src={url} name={fileName ?? ""} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* @generated SignedSource<<22d97ce5cead5a1cc6c23e89fca72cb1>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
export type AcceptNonDisclosureAgreementInput = {
|
||||
fullName: string;
|
||||
};
|
||||
export type NDADialogSignMutation$variables = {
|
||||
input: AcceptNonDisclosureAgreementInput;
|
||||
};
|
||||
export type NDADialogSignMutation$data = {
|
||||
readonly acceptNonDisclosureAgreement: {
|
||||
readonly success: boolean;
|
||||
} | null | undefined;
|
||||
};
|
||||
export type NDADialogSignMutation = {
|
||||
response: NDADialogSignMutation$data;
|
||||
variables: NDADialogSignMutation$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = [
|
||||
{
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "input"
|
||||
}
|
||||
],
|
||||
v1 = [
|
||||
{
|
||||
"alias": null,
|
||||
"args": [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "input",
|
||||
"variableName": "input"
|
||||
}
|
||||
],
|
||||
"concreteType": "AcceptNonDisclosureAgreementPayload",
|
||||
"kind": "LinkedField",
|
||||
"name": "acceptNonDisclosureAgreement",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "success",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
];
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "NDADialogSignMutation",
|
||||
"selections": (v1/*: any*/),
|
||||
"type": "Mutation",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Operation",
|
||||
"name": "NDADialogSignMutation",
|
||||
"selections": (v1/*: any*/)
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "130cfc307dca0525194e0103a0548bd0",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "NDADialogSignMutation",
|
||||
"operationKind": "mutation",
|
||||
"text": "mutation NDADialogSignMutation(\n $input: AcceptNonDisclosureAgreementInput!\n) {\n acceptNonDisclosureAgreement(input: $input) {\n success\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "1b9447e5cbb2ec7dce4f3f9c68493555";
|
||||
|
||||
export default node;
|
||||
@@ -2,9 +2,8 @@ import { useFavicon, useSystemTheme } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Logo, TabLink, Tabs } from "@probo/ui";
|
||||
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
import { Outlet } from "react-router";
|
||||
import { Navigate, Outlet } from "react-router";
|
||||
|
||||
import { NDADialog } from "#/components/NDADialog";
|
||||
import { OrganizationSidebar } from "#/components/OrganizationSidebar";
|
||||
import { TrustCenterProvider } from "#/providers/TrustCenterProvider";
|
||||
import { Viewer } from "#/providers/Viewer";
|
||||
@@ -28,20 +27,20 @@ export function MainLayout(props: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showNDADialog
|
||||
const ndaSig = trustCenter.ndaSignature;
|
||||
const hasPendingNDA
|
||||
= trustCenter.isViewerMember
|
||||
&& !trustCenter.hasAcceptedNonDisclosureAgreement
|
||||
&& trustCenter.ndaFileUrl;
|
||||
&& trustCenter.ndaFileUrl
|
||||
&& ndaSig
|
||||
&& ndaSig.status !== "COMPLETED";
|
||||
|
||||
if (hasPendingNDA) {
|
||||
return <Navigate to="/nda" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Viewer value={data.viewer}>
|
||||
<TrustCenterProvider trustCenter={trustCenter}>
|
||||
{showNDADialog && (
|
||||
<NDADialog
|
||||
organizationName={trustCenter.organization.name}
|
||||
url={trustCenter.ndaFileUrl}
|
||||
fileName={trustCenter.ndaFileName}
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-cols-1 max-w-[1280px] mx-4 pt-6 gap-4 lg:mx-auto lg:gap-10 lg:pt-20 lg:grid-cols-[400px_1fr] lg:items-start ">
|
||||
<OrganizationSidebar trustCenter={trustCenter} />
|
||||
<main>
|
||||
|
||||
332
apps/trust/src/pages/NDAPage.tsx
Normal file
332
apps/trust/src/pages/NDAPage.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button, Card, Field, IconCircleX, Logo, Spinner } from "@probo/ui";
|
||||
import { startTransition, useEffect, useRef } from "react";
|
||||
import {
|
||||
type PreloadedQuery,
|
||||
useMutation,
|
||||
usePreloadedQuery,
|
||||
useRefetchableFragment,
|
||||
} from "react-relay";
|
||||
import { Navigate, useNavigate } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { useWindowSize } from "usehooks-ts";
|
||||
import { z } from "zod";
|
||||
|
||||
import { PDFPreview } from "#/components/PDFPreview";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
|
||||
import type { NDAPageAcceptElectronicSignatureMutation } from "./__generated__/NDAPageAcceptElectronicSignatureMutation.graphql";
|
||||
import type { NDAPageFragment$key } from "./__generated__/NDAPageFragment.graphql";
|
||||
import type { NDAPageQuery as NDAPageQueryType } from "./__generated__/NDAPageQuery.graphql";
|
||||
import type { NDAPageRecordSigningEventMutation } from "./__generated__/NDAPageRecordSigningEventMutation.graphql";
|
||||
import type { NDAPageRefetchQuery } from "./__generated__/NDAPageRefetchQuery.graphql";
|
||||
|
||||
export const ndaPageQuery = graphql`
|
||||
query NDAPageQuery {
|
||||
viewer {
|
||||
fullName
|
||||
}
|
||||
currentTrustCenter @required(action: THROW) {
|
||||
organization {
|
||||
name
|
||||
}
|
||||
ndaFileUrl
|
||||
ndaFileName
|
||||
ndaSignature {
|
||||
status
|
||||
}
|
||||
...NDAPageFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ndaPageFragment = graphql`
|
||||
fragment NDAPageFragment on TrustCenter
|
||||
@refetchable(queryName: "NDAPageRefetchQuery") {
|
||||
ndaSignature @required(action: THROW) {
|
||||
id
|
||||
status
|
||||
consentText
|
||||
lastError
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const acceptElectronicSignatureMutation = graphql`
|
||||
mutation NDAPageAcceptElectronicSignatureMutation(
|
||||
$input: AcceptElectronicSignatureInput!
|
||||
) {
|
||||
acceptElectronicSignature(input: $input) {
|
||||
signature {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const recordSigningEventMutation = graphql`
|
||||
mutation NDAPageRecordSigningEventMutation(
|
||||
$input: RecordSigningEventInput!
|
||||
) {
|
||||
recordSigningEvent(input: $input) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const schema = z.object({
|
||||
fullName: z.string().min(1),
|
||||
});
|
||||
|
||||
export function NDAPage(props: {
|
||||
queryRef: PreloadedQuery<NDAPageQueryType>;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const navigate = useNavigate();
|
||||
const documentViewedRef = useRef(false);
|
||||
|
||||
const queryData = usePreloadedQuery(ndaPageQuery, props.queryRef);
|
||||
const trustCenter = queryData.currentTrustCenter;
|
||||
const viewer = queryData.viewer;
|
||||
|
||||
const [data, refetch] = useRefetchableFragment<NDAPageRefetchQuery, NDAPageFragment$key>(
|
||||
ndaPageFragment,
|
||||
trustCenter,
|
||||
);
|
||||
const ndaSignature = data.ndaSignature;
|
||||
|
||||
const { width } = useWindowSize();
|
||||
const isMobile = width < 1100;
|
||||
const isDesktop = !isMobile;
|
||||
|
||||
const {
|
||||
handleSubmit: handleSubmitWrapper,
|
||||
register,
|
||||
formState,
|
||||
} = useFormWithSchema(schema, {
|
||||
defaultValues: {
|
||||
fullName: viewer?.fullName,
|
||||
},
|
||||
});
|
||||
|
||||
const [acceptSignature, isAccepting] = useMutation<NDAPageAcceptElectronicSignatureMutation>(
|
||||
acceptElectronicSignatureMutation,
|
||||
);
|
||||
|
||||
const [recordSigningEvent] = useMutation<NDAPageRecordSigningEventMutation>(
|
||||
recordSigningEventMutation,
|
||||
);
|
||||
|
||||
const isProcessing
|
||||
= ndaSignature.status === "ACCEPTED"
|
||||
|| ndaSignature.status === "PROCESSING";
|
||||
|
||||
const isFailed = ndaSignature.status === "FAILED";
|
||||
const isCompleted = ndaSignature.status === "COMPLETED";
|
||||
|
||||
useEffect(() => {
|
||||
if (isCompleted) {
|
||||
navigate("/overview", { replace: true });
|
||||
}
|
||||
}, [isCompleted, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isProcessing) return;
|
||||
|
||||
const poll = () => startTransition(() => {
|
||||
refetch({}, { fetchPolicy: "network-only" });
|
||||
});
|
||||
const interval = setInterval(poll, 1500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isProcessing, refetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
ndaSignature
|
||||
&& ndaSignature.status === "PENDING"
|
||||
&& !documentViewedRef.current
|
||||
) {
|
||||
documentViewedRef.current = true;
|
||||
recordSigningEvent({
|
||||
variables: {
|
||||
input: {
|
||||
signatureId: ndaSignature.id,
|
||||
eventType: "DOCUMENT_VIEWED",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [ndaSignature, recordSigningEvent]);
|
||||
|
||||
const handleSubmit = handleSubmitWrapper(({ fullName }) => {
|
||||
if (!ndaSignature) return;
|
||||
|
||||
if (ndaSignature.status === "PENDING") {
|
||||
recordSigningEvent({
|
||||
variables: {
|
||||
input: {
|
||||
signatureId: ndaSignature.id,
|
||||
eventType: "FULL_NAME_TYPED",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
recordSigningEvent({
|
||||
variables: {
|
||||
input: {
|
||||
signatureId: ndaSignature.id,
|
||||
eventType: "CONSENT_GIVEN",
|
||||
},
|
||||
},
|
||||
onCompleted: () => {
|
||||
acceptSignature({
|
||||
variables: {
|
||||
input: {
|
||||
signatureId: ndaSignature.id,
|
||||
fullName,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const handleTryAgain = () => {
|
||||
if (ndaSignature && viewer?.fullName) {
|
||||
acceptSignature({
|
||||
variables: {
|
||||
input: {
|
||||
signatureId: ndaSignature.id,
|
||||
fullName: viewer.fullName,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
!trustCenter.ndaFileUrl
|
||||
|| !trustCenter.ndaSignature
|
||||
|| trustCenter.ndaSignature.status === "COMPLETED"
|
||||
) {
|
||||
return <Navigate to="/overview" replace />;
|
||||
}
|
||||
|
||||
const consentText = ndaSignature?.consentText
|
||||
? ndaSignature.consentText
|
||||
: __(
|
||||
"By clicking Review & Sign, you agree to the terms of this NDA. If you have questions about the NDA, please contact security@probo.com.",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-level-2 flex flex-col min-h-screen lg:h-screen">
|
||||
<header className="flex items-center h-12 justify-between border-b border-border-solid px-4 flex-none">
|
||||
<Logo />
|
||||
</header>
|
||||
<div className="grid lg:grid-cols-2 min-h-0 flex-1">
|
||||
<div className="flex flex-col items-center overflow-y-auto">
|
||||
<div className="max-w-[440px] w-full mx-auto px-4 py-12 lg:py-20 flex-1">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{__("Non-Disclosure Agreement")}
|
||||
</h1>
|
||||
<p className="text-txt-secondary mt-2">
|
||||
{sprintf(
|
||||
__(
|
||||
"%s requires you to sign an NDA before accessing compliance documents.",
|
||||
),
|
||||
trustCenter.organization.name,
|
||||
)}
|
||||
</p>
|
||||
{isMobile && trustCenter.ndaFileUrl && (
|
||||
<Card className="flex justify-between py-3 px-4 text-sm items-center mt-6">
|
||||
{trustCenter.ndaFileName}
|
||||
<Button variant="secondary" asChild>
|
||||
<a target="_blank" rel="noopener noreferrer" href={trustCenter.ndaFileUrl}>
|
||||
{__("View document")}
|
||||
</a>
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
<form
|
||||
onSubmit={
|
||||
isFailed
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
handleTryAgain();
|
||||
}
|
||||
: e => void handleSubmit(e)
|
||||
}
|
||||
className="mt-8"
|
||||
>
|
||||
<Field
|
||||
required
|
||||
label={__("Full name")}
|
||||
placeholder="John Doe"
|
||||
{...register("fullName")}
|
||||
type="text"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<p className="text-xs text-txt-tertiary mt-6">
|
||||
{consentText}
|
||||
</p>
|
||||
{isFailed && (
|
||||
<div className="flex items-start gap-2 mt-4 rounded-md bg-red-50 border border-red-200 p-3 text-sm text-red-800">
|
||||
<IconCircleX size={16} className="mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{__("Signature processing failed")}
|
||||
</p>
|
||||
<p className="mt-0.5 text-red-600">
|
||||
{ndaSignature?.lastError
|
||||
?? __("We encountered an issue processing your signature. Please try again.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isProcessing
|
||||
? (
|
||||
<Button
|
||||
type="button"
|
||||
className="h-10 w-full mt-4"
|
||||
disabled
|
||||
icon={Spinner}
|
||||
>
|
||||
{__("Sealing your signature...")}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-10 w-full mt-4"
|
||||
disabled={formState.isSubmitting || !formState.isValid}
|
||||
icon={isAccepting ? Spinner : undefined}
|
||||
>
|
||||
{isFailed
|
||||
? __("Try again")
|
||||
: __("Accept")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
<a
|
||||
href="https://www.getprobo.com/"
|
||||
className="flex gap-1 text-sm font-medium text-txt-tertiary items-center py-6"
|
||||
>
|
||||
Powered by
|
||||
{" "}
|
||||
<Logo withPicto className="h-6" />
|
||||
</a>
|
||||
</div>
|
||||
{isDesktop && (
|
||||
<div className="bg-subtle h-full border-l border-border-solid min-h-0">
|
||||
{trustCenter.ndaFileUrl && <PDFPreview src={trustCenter.ndaFileUrl} name={trustCenter.ndaFileName ?? ""} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
apps/trust/src/pages/NDAPageLoader.tsx
Normal file
34
apps/trust/src/pages/NDAPageLoader.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
|
||||
import { RelayProvider } from "#/providers/RelayProviders";
|
||||
|
||||
import type { NDAPageQuery } from "./__generated__/NDAPageQuery.graphql";
|
||||
import { NDAPage, ndaPageQuery } from "./NDAPage";
|
||||
|
||||
function NDAPageQueryLoader() {
|
||||
const [queryRef, loadQuery]
|
||||
= useQueryLoader<NDAPageQuery>(ndaPageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queryRef) {
|
||||
loadQuery({});
|
||||
}
|
||||
});
|
||||
|
||||
if (!queryRef) return null;
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<NDAPage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NDAPageLoader() {
|
||||
return (
|
||||
<RelayProvider>
|
||||
<NDAPageQueryLoader />
|
||||
</RelayProvider>
|
||||
);
|
||||
}
|
||||
115
apps/trust/src/pages/__generated__/NDAPageAcceptElectronicSignatureMutation.graphql.ts
generated
Normal file
115
apps/trust/src/pages/__generated__/NDAPageAcceptElectronicSignatureMutation.graphql.ts
generated
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @generated SignedSource<<41e415a03c63efbb590b1c2c782d5eac>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
export type ElectronicSignatureStatus = "ACCEPTED" | "COMPLETED" | "FAILED" | "PENDING" | "PROCESSING";
|
||||
export type AcceptElectronicSignatureInput = {
|
||||
fullName: string;
|
||||
signatureId: string;
|
||||
};
|
||||
export type NDAPageAcceptElectronicSignatureMutation$variables = {
|
||||
input: AcceptElectronicSignatureInput;
|
||||
};
|
||||
export type NDAPageAcceptElectronicSignatureMutation$data = {
|
||||
readonly acceptElectronicSignature: {
|
||||
readonly signature: {
|
||||
readonly id: string;
|
||||
readonly status: ElectronicSignatureStatus;
|
||||
};
|
||||
} | null | undefined;
|
||||
};
|
||||
export type NDAPageAcceptElectronicSignatureMutation = {
|
||||
response: NDAPageAcceptElectronicSignatureMutation$data;
|
||||
variables: NDAPageAcceptElectronicSignatureMutation$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = [
|
||||
{
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "input"
|
||||
}
|
||||
],
|
||||
v1 = [
|
||||
{
|
||||
"alias": null,
|
||||
"args": [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "input",
|
||||
"variableName": "input"
|
||||
}
|
||||
],
|
||||
"concreteType": "AcceptElectronicSignaturePayload",
|
||||
"kind": "LinkedField",
|
||||
"name": "acceptElectronicSignature",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "ElectronicSignature",
|
||||
"kind": "LinkedField",
|
||||
"name": "signature",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "id",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "status",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
];
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "NDAPageAcceptElectronicSignatureMutation",
|
||||
"selections": (v1/*: any*/),
|
||||
"type": "Mutation",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Operation",
|
||||
"name": "NDAPageAcceptElectronicSignatureMutation",
|
||||
"selections": (v1/*: any*/)
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "db0c219a7133025b56252836d7f1a85d",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "NDAPageAcceptElectronicSignatureMutation",
|
||||
"operationKind": "mutation",
|
||||
"text": "mutation NDAPageAcceptElectronicSignatureMutation(\n $input: AcceptElectronicSignatureInput!\n) {\n acceptElectronicSignature(input: $input) {\n signature {\n id\n status\n }\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "b4ab543bd58878bb4968de6ea8b7c448";
|
||||
|
||||
export default node;
|
||||
103
apps/trust/src/pages/__generated__/NDAPageFragment.graphql.ts
generated
Normal file
103
apps/trust/src/pages/__generated__/NDAPageFragment.graphql.ts
generated
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @generated SignedSource<<93f8676826dfa28c1681da68a6ac1cf2>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ReaderFragment } from 'relay-runtime';
|
||||
export type ElectronicSignatureStatus = "ACCEPTED" | "COMPLETED" | "FAILED" | "PENDING" | "PROCESSING";
|
||||
import { FragmentRefs } from "relay-runtime";
|
||||
export type NDAPageFragment$data = {
|
||||
readonly id: string;
|
||||
readonly ndaSignature: {
|
||||
readonly consentText: string;
|
||||
readonly id: string;
|
||||
readonly lastError: string | null | undefined;
|
||||
readonly status: ElectronicSignatureStatus;
|
||||
};
|
||||
readonly " $fragmentType": "NDAPageFragment";
|
||||
};
|
||||
export type NDAPageFragment$key = {
|
||||
readonly " $data"?: NDAPageFragment$data;
|
||||
readonly " $fragmentSpreads": FragmentRefs<"NDAPageFragment">;
|
||||
};
|
||||
|
||||
import NDAPageRefetchQuery_graphql from './NDAPageRefetchQuery.graphql';
|
||||
|
||||
const node: ReaderFragment = (function(){
|
||||
var v0 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "id",
|
||||
"storageKey": null
|
||||
};
|
||||
return {
|
||||
"argumentDefinitions": [],
|
||||
"kind": "Fragment",
|
||||
"metadata": {
|
||||
"refetch": {
|
||||
"connection": null,
|
||||
"fragmentPathInResult": [
|
||||
"node"
|
||||
],
|
||||
"operation": NDAPageRefetchQuery_graphql,
|
||||
"identifierInfo": {
|
||||
"identifierField": "id",
|
||||
"identifierQueryVariableName": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": "NDAPageFragment",
|
||||
"selections": [
|
||||
{
|
||||
"kind": "RequiredField",
|
||||
"field": {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "ElectronicSignature",
|
||||
"kind": "LinkedField",
|
||||
"name": "ndaSignature",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v0/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "status",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "consentText",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "lastError",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
"action": "THROW"
|
||||
},
|
||||
(v0/*: any*/)
|
||||
],
|
||||
"type": "TrustCenter",
|
||||
"abstractKey": null
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "b53a5f455508ed45124b7d09cfb573da";
|
||||
|
||||
export default node;
|
||||
236
apps/trust/src/pages/__generated__/NDAPageQuery.graphql.ts
generated
Normal file
236
apps/trust/src/pages/__generated__/NDAPageQuery.graphql.ts
generated
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* @generated SignedSource<<f1cae8462501cdf634eb02eb143c3806>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
import { FragmentRefs } from "relay-runtime";
|
||||
export type ElectronicSignatureStatus = "ACCEPTED" | "COMPLETED" | "FAILED" | "PENDING" | "PROCESSING";
|
||||
export type NDAPageQuery$variables = Record<PropertyKey, never>;
|
||||
export type NDAPageQuery$data = {
|
||||
readonly currentTrustCenter: {
|
||||
readonly ndaFileName: string | null | undefined;
|
||||
readonly ndaFileUrl: string | null | undefined;
|
||||
readonly ndaSignature: {
|
||||
readonly status: ElectronicSignatureStatus;
|
||||
} | null | undefined;
|
||||
readonly organization: {
|
||||
readonly name: string;
|
||||
};
|
||||
readonly " $fragmentSpreads": FragmentRefs<"NDAPageFragment">;
|
||||
};
|
||||
readonly viewer: {
|
||||
readonly fullName: string;
|
||||
} | null | undefined;
|
||||
};
|
||||
export type NDAPageQuery = {
|
||||
response: NDAPageQuery$data;
|
||||
variables: NDAPageQuery$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "fullName",
|
||||
"storageKey": null
|
||||
},
|
||||
v1 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "name",
|
||||
"storageKey": null
|
||||
},
|
||||
v2 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "ndaFileUrl",
|
||||
"storageKey": null
|
||||
},
|
||||
v3 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "ndaFileName",
|
||||
"storageKey": null
|
||||
},
|
||||
v4 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "status",
|
||||
"storageKey": null
|
||||
},
|
||||
v5 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "id",
|
||||
"storageKey": null
|
||||
};
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": [],
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "NDAPageQuery",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Identity",
|
||||
"kind": "LinkedField",
|
||||
"name": "viewer",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v0/*: any*/)
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"kind": "RequiredField",
|
||||
"field": {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "TrustCenter",
|
||||
"kind": "LinkedField",
|
||||
"name": "currentTrustCenter",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Organization",
|
||||
"kind": "LinkedField",
|
||||
"name": "organization",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v1/*: any*/)
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
(v2/*: any*/),
|
||||
(v3/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "ElectronicSignature",
|
||||
"kind": "LinkedField",
|
||||
"name": "ndaSignature",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v4/*: any*/)
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"args": null,
|
||||
"kind": "FragmentSpread",
|
||||
"name": "NDAPageFragment"
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
"action": "THROW"
|
||||
}
|
||||
],
|
||||
"type": "Query",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": [],
|
||||
"kind": "Operation",
|
||||
"name": "NDAPageQuery",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Identity",
|
||||
"kind": "LinkedField",
|
||||
"name": "viewer",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v0/*: any*/),
|
||||
(v5/*: any*/)
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "TrustCenter",
|
||||
"kind": "LinkedField",
|
||||
"name": "currentTrustCenter",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Organization",
|
||||
"kind": "LinkedField",
|
||||
"name": "organization",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v1/*: any*/),
|
||||
(v5/*: any*/)
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
(v2/*: any*/),
|
||||
(v3/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "ElectronicSignature",
|
||||
"kind": "LinkedField",
|
||||
"name": "ndaSignature",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v4/*: any*/),
|
||||
(v5/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "consentText",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "lastError",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
(v5/*: any*/)
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "c0b175c435c5730e390fcfde596d8bc3",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "NDAPageQuery",
|
||||
"operationKind": "query",
|
||||
"text": "query NDAPageQuery {\n viewer {\n fullName\n id\n }\n currentTrustCenter {\n organization {\n name\n id\n }\n ndaFileUrl\n ndaFileName\n ndaSignature {\n status\n id\n }\n ...NDAPageFragment\n id\n }\n}\n\nfragment NDAPageFragment on TrustCenter {\n ndaSignature {\n id\n status\n consentText\n lastError\n }\n id\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "a78bf8b65feba4627ecb265ca882f645";
|
||||
|
||||
export default node;
|
||||
94
apps/trust/src/pages/__generated__/NDAPageRecordSigningEventMutation.graphql.ts
generated
Normal file
94
apps/trust/src/pages/__generated__/NDAPageRecordSigningEventMutation.graphql.ts
generated
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @generated SignedSource<<d2d531924e3667658020b431850c193e>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
export type ElectronicSignatureEventType = "CERTIFICATE_GENERATED" | "CONSENT_GIVEN" | "DOCUMENT_VIEWED" | "FULL_NAME_TYPED" | "SEAL_COMPUTED" | "SIGNATURE_ACCEPTED" | "SIGNATURE_COMPLETED" | "TIMESTAMP_REQUESTED";
|
||||
export type RecordSigningEventInput = {
|
||||
eventType: ElectronicSignatureEventType;
|
||||
signatureId: string;
|
||||
};
|
||||
export type NDAPageRecordSigningEventMutation$variables = {
|
||||
input: RecordSigningEventInput;
|
||||
};
|
||||
export type NDAPageRecordSigningEventMutation$data = {
|
||||
readonly recordSigningEvent: {
|
||||
readonly success: boolean;
|
||||
} | null | undefined;
|
||||
};
|
||||
export type NDAPageRecordSigningEventMutation = {
|
||||
response: NDAPageRecordSigningEventMutation$data;
|
||||
variables: NDAPageRecordSigningEventMutation$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = [
|
||||
{
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "input"
|
||||
}
|
||||
],
|
||||
v1 = [
|
||||
{
|
||||
"alias": null,
|
||||
"args": [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "input",
|
||||
"variableName": "input"
|
||||
}
|
||||
],
|
||||
"concreteType": "RecordSigningEventPayload",
|
||||
"kind": "LinkedField",
|
||||
"name": "recordSigningEvent",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "success",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
];
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "NDAPageRecordSigningEventMutation",
|
||||
"selections": (v1/*: any*/),
|
||||
"type": "Mutation",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Operation",
|
||||
"name": "NDAPageRecordSigningEventMutation",
|
||||
"selections": (v1/*: any*/)
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "72aa3c3ec642d5bbb317a6e2a65703de",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "NDAPageRecordSigningEventMutation",
|
||||
"operationKind": "mutation",
|
||||
"text": "mutation NDAPageRecordSigningEventMutation(\n $input: RecordSigningEventInput!\n) {\n recordSigningEvent(input: $input) {\n success\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "ec40eb438c6d6b20f31974e099535887";
|
||||
|
||||
export default node;
|
||||
155
apps/trust/src/pages/__generated__/NDAPageRefetchQuery.graphql.ts
generated
Normal file
155
apps/trust/src/pages/__generated__/NDAPageRefetchQuery.graphql.ts
generated
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @generated SignedSource<<47d15b007fc81de3850393c9b158726c>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
import { FragmentRefs } from "relay-runtime";
|
||||
export type NDAPageRefetchQuery$variables = {
|
||||
id: string;
|
||||
};
|
||||
export type NDAPageRefetchQuery$data = {
|
||||
readonly node: {
|
||||
readonly " $fragmentSpreads": FragmentRefs<"NDAPageFragment">;
|
||||
};
|
||||
};
|
||||
export type NDAPageRefetchQuery = {
|
||||
response: NDAPageRefetchQuery$data;
|
||||
variables: NDAPageRefetchQuery$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = [
|
||||
{
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "id"
|
||||
}
|
||||
],
|
||||
v1 = [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "id",
|
||||
"variableName": "id"
|
||||
}
|
||||
],
|
||||
v2 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "id",
|
||||
"storageKey": null
|
||||
};
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "NDAPageRefetchQuery",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v1/*: any*/),
|
||||
"concreteType": null,
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"args": null,
|
||||
"kind": "FragmentSpread",
|
||||
"name": "NDAPageFragment"
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"type": "Query",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Operation",
|
||||
"name": "NDAPageRefetchQuery",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v1/*: any*/),
|
||||
"concreteType": null,
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "__typename",
|
||||
"storageKey": null
|
||||
},
|
||||
(v2/*: any*/),
|
||||
{
|
||||
"kind": "InlineFragment",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "ElectronicSignature",
|
||||
"kind": "LinkedField",
|
||||
"name": "ndaSignature",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v2/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "status",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "consentText",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "lastError",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"type": "TrustCenter",
|
||||
"abstractKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "1c61baa555ad2b24794ce0bf5ecc14ac",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "NDAPageRefetchQuery",
|
||||
"operationKind": "query",
|
||||
"text": "query NDAPageRefetchQuery(\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...NDAPageFragment\n id\n }\n}\n\nfragment NDAPageFragment on TrustCenter {\n ndaSignature {\n id\n status\n consentText\n lastError\n }\n id\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "b53a5f455508ed45124b7d09cfb573da";
|
||||
|
||||
export default node;
|
||||
@@ -11,11 +11,13 @@ export const currentTrustGraphQuery = graphql`
|
||||
id
|
||||
slug
|
||||
isViewerMember
|
||||
hasAcceptedNonDisclosureAgreement
|
||||
logoFileUrl
|
||||
darkLogoFileUrl
|
||||
ndaFileName
|
||||
ndaFileUrl
|
||||
ndaSignature {
|
||||
status
|
||||
}
|
||||
organization {
|
||||
name
|
||||
description
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @generated SignedSource<<006540288cfca33589264e1f596e1028>>
|
||||
* @generated SignedSource<<4bf04c5599ef88c6104919907fb182dd>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
import { FragmentRefs } from "relay-runtime";
|
||||
export type ElectronicSignatureStatus = "ACCEPTED" | "COMPLETED" | "FAILED" | "PENDING" | "PROCESSING";
|
||||
export type TrustGraphCurrentQuery$variables = Record<PropertyKey, never>;
|
||||
export type TrustGraphCurrentQuery$data = {
|
||||
readonly currentTrustCenter: {
|
||||
@@ -22,12 +23,14 @@ export type TrustGraphCurrentQuery$data = {
|
||||
}>;
|
||||
};
|
||||
readonly darkLogoFileUrl: string | null | undefined;
|
||||
readonly hasAcceptedNonDisclosureAgreement: boolean;
|
||||
readonly id: string;
|
||||
readonly isViewerMember: boolean;
|
||||
readonly logoFileUrl: string | null | undefined;
|
||||
readonly ndaFileName: string | null | undefined;
|
||||
readonly ndaFileUrl: string | null | undefined;
|
||||
readonly ndaSignature: {
|
||||
readonly status: ElectronicSignatureStatus;
|
||||
} | null | undefined;
|
||||
readonly organization: {
|
||||
readonly description: string | null | undefined;
|
||||
readonly email: string | null | undefined;
|
||||
@@ -91,35 +94,35 @@ v5 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "hasAcceptedNonDisclosureAgreement",
|
||||
"name": "logoFileUrl",
|
||||
"storageKey": null
|
||||
},
|
||||
v6 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "logoFileUrl",
|
||||
"name": "darkLogoFileUrl",
|
||||
"storageKey": null
|
||||
},
|
||||
v7 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "darkLogoFileUrl",
|
||||
"name": "ndaFileName",
|
||||
"storageKey": null
|
||||
},
|
||||
v8 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "ndaFileName",
|
||||
"name": "ndaFileUrl",
|
||||
"storageKey": null
|
||||
},
|
||||
v9 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "ndaFileUrl",
|
||||
"name": "status",
|
||||
"storageKey": null
|
||||
},
|
||||
v10 = {
|
||||
@@ -237,7 +240,18 @@ return {
|
||||
(v6/*: any*/),
|
||||
(v7/*: any*/),
|
||||
(v8/*: any*/),
|
||||
(v9/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "ElectronicSignature",
|
||||
"kind": "LinkedField",
|
||||
"name": "ndaSignature",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v9/*: any*/)
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
@@ -341,7 +355,19 @@ return {
|
||||
(v6/*: any*/),
|
||||
(v7/*: any*/),
|
||||
(v8/*: any*/),
|
||||
(v9/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "ElectronicSignature",
|
||||
"kind": "LinkedField",
|
||||
"name": "ndaSignature",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v9/*: any*/),
|
||||
(v2/*: any*/)
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
@@ -641,16 +667,16 @@ return {
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "af3118fae0bbc255713aae845ab83032",
|
||||
"cacheID": "5756e245b9ad1dc00d25c505e4764a62",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "TrustGraphCurrentQuery",
|
||||
"operationKind": "query",
|
||||
"text": "query TrustGraphCurrentQuery {\n viewer {\n email\n fullName\n id\n }\n currentTrustCenter {\n id\n slug\n isViewerMember\n hasAcceptedNonDisclosureAgreement\n logoFileUrl\n darkLogoFileUrl\n ndaFileName\n ndaFileUrl\n organization {\n name\n description\n websiteUrl\n email\n headquarterAddress\n id\n }\n ...OverviewPageFragment\n vendorInfo: vendors(first: 0) {\n totalCount\n }\n audits(first: 50) {\n edges {\n node {\n id\n ...AuditRowFragment\n }\n }\n }\n }\n}\n\nfragment AuditRowFragment on Audit {\n report {\n id\n filename\n isUserAuthorized\n hasUserRequestedAccess\n }\n framework {\n id\n name\n lightLogoURL\n darkLogoURL\n }\n}\n\nfragment DocumentRowFragment on Document {\n id\n title\n isUserAuthorized\n hasUserRequestedAccess\n}\n\nfragment OverviewPageFragment on TrustCenter {\n references(first: 14) {\n edges {\n node {\n id\n name\n logoUrl\n websiteUrl\n }\n }\n }\n vendors(first: 3) {\n edges {\n node {\n id\n countries\n ...VendorRowFragment\n }\n }\n }\n documents(first: 5) {\n edges {\n node {\n id\n ...DocumentRowFragment\n documentType\n }\n }\n }\n trustCenterFiles(first: 5) {\n edges {\n node {\n id\n category\n ...TrustCenterFileRowFragment\n }\n }\n }\n}\n\nfragment TrustCenterFileRowFragment on TrustCenterFile {\n id\n name\n isUserAuthorized\n hasUserRequestedAccess\n}\n\nfragment VendorRowFragment on Vendor {\n name\n description\n websiteUrl\n countries\n}\n"
|
||||
"text": "query TrustGraphCurrentQuery {\n viewer {\n email\n fullName\n id\n }\n currentTrustCenter {\n id\n slug\n isViewerMember\n logoFileUrl\n darkLogoFileUrl\n ndaFileName\n ndaFileUrl\n ndaSignature {\n status\n id\n }\n organization {\n name\n description\n websiteUrl\n email\n headquarterAddress\n id\n }\n ...OverviewPageFragment\n vendorInfo: vendors(first: 0) {\n totalCount\n }\n audits(first: 50) {\n edges {\n node {\n id\n ...AuditRowFragment\n }\n }\n }\n }\n}\n\nfragment AuditRowFragment on Audit {\n report {\n id\n filename\n isUserAuthorized\n hasUserRequestedAccess\n }\n framework {\n id\n name\n lightLogoURL\n darkLogoURL\n }\n}\n\nfragment DocumentRowFragment on Document {\n id\n title\n isUserAuthorized\n hasUserRequestedAccess\n}\n\nfragment OverviewPageFragment on TrustCenter {\n references(first: 14) {\n edges {\n node {\n id\n name\n logoUrl\n websiteUrl\n }\n }\n }\n vendors(first: 3) {\n edges {\n node {\n id\n countries\n ...VendorRowFragment\n }\n }\n }\n documents(first: 5) {\n edges {\n node {\n id\n ...DocumentRowFragment\n documentType\n }\n }\n }\n trustCenterFiles(first: 5) {\n edges {\n node {\n id\n category\n ...TrustCenterFileRowFragment\n }\n }\n }\n}\n\nfragment TrustCenterFileRowFragment on TrustCenterFile {\n id\n name\n isUserAuthorized\n hasUserRequestedAccess\n}\n\nfragment VendorRowFragment on Vendor {\n name\n description\n websiteUrl\n countries\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "566fdb9af999e51fdf50e2eccca60941";
|
||||
(node as any).hash = "3b8d99e97c59c613f0ef2f01951a5221";
|
||||
|
||||
export default node;
|
||||
|
||||
@@ -56,6 +56,11 @@ const routes = [
|
||||
Component: Fragment,
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
{
|
||||
path: "/nda",
|
||||
Component: lazy(() => import("#/pages/NDAPageLoader")),
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
// Custom domain routes (subdomain-based)
|
||||
{
|
||||
path: "/overview",
|
||||
|
||||
6
go.mod
6
go.mod
@@ -13,9 +13,11 @@ require (
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
|
||||
github.com/chromedp/chromedp v0.14.2
|
||||
github.com/crewjam/saml v0.5.1
|
||||
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea
|
||||
github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8
|
||||
github.com/go-chi/chi/v5 v5.2.4
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/jhillyerd/enmime v1.3.0
|
||||
github.com/modelcontextprotocol/go-sdk v1.2.0
|
||||
@@ -29,7 +31,7 @@ require (
|
||||
github.com/yuin/goldmark v1.7.16
|
||||
go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712
|
||||
go.gearno.de/kit v0.1.1
|
||||
go.gearno.de/x/ref v0.0.0-20240502200927-d74926fcb14c
|
||||
go.gearno.de/x/ref v0.0.0-20260216110753-a700c951377c
|
||||
go.opentelemetry.io/otel v1.39.0
|
||||
go.opentelemetry.io/otel/trace v1.39.0
|
||||
go.probo.inc/mcpgen v0.0.0-20251124210642-41a5174eb92f
|
||||
@@ -66,6 +68,7 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/di-wu/parser v0.3.0 // indirect
|
||||
github.com/di-wu/xsd-datetime v1.0.0 // indirect
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 // indirect
|
||||
github.com/dnephin/pflag v1.0.7 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
@@ -82,7 +85,6 @@ require (
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.9 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
8
go.sum
8
go.sum
@@ -77,6 +77,10 @@ github.com/di-wu/parser v0.3.0 h1:NMOvy5ifswgt4gsdhySVcKOQtvjC43cHZIfViWctqQY=
|
||||
github.com/di-wu/parser v0.3.0/go.mod h1:SLp58pW6WamdmznrVRrw2NTyn4wAvT9rrEFynKX7nYo=
|
||||
github.com/di-wu/xsd-datetime v1.0.0 h1:vZoGNkbzpBNoc+JyfVLEbutNDNydYV8XwHeV7eUJoxI=
|
||||
github.com/di-wu/xsd-datetime v1.0.0/go.mod h1:i3iEhrP3WchwseOBeIdW/zxeoleXTOzx1WyDXgdmOww=
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 h1:h+XMRXf+WLY0h/3itqE8OT3TgjCMHK4nq2FNGi0au2c=
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc=
|
||||
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53QmnN3Bcj0NpR8SsFLnskny/EIMebAk1c=
|
||||
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y=
|
||||
github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk=
|
||||
github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
|
||||
github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8 h1:0+BTyxIYgiVAry/P5s8R4dYuLkhB9Nhso8ogFWNr4IQ=
|
||||
@@ -255,8 +259,8 @@ go.gearno.de/kit v0.1.1 h1:QuBZCZ/h2Eyh6DjjR6CGjkdsab/ztHz6xUiIk0FeREE=
|
||||
go.gearno.de/kit v0.1.1/go.mod h1:WI/gQ14O9M6wsKa/HFL4ZH+Q/U0930hYWZRdHHo9Agk=
|
||||
go.gearno.de/x/panicf v0.1.1 h1:E3Cr9NB8Ry2EsvEG/1eHr7kplP3tEjTf5d56dTX64VQ=
|
||||
go.gearno.de/x/panicf v0.1.1/go.mod h1:VnB8oF0UefMZcYeD4v+Wk4U5Z1uza7PHLlhT2CbNEbU=
|
||||
go.gearno.de/x/ref v0.0.0-20240502200927-d74926fcb14c h1:b8Wwr2owaB6g38tptSk5tekXFSE7RpXsUpaoCO8tQDM=
|
||||
go.gearno.de/x/ref v0.0.0-20240502200927-d74926fcb14c/go.mod h1:k3GtgnI5X9dl8FlqaNYkCkil7/iACQdFOromU/H4u6I=
|
||||
go.gearno.de/x/ref v0.0.0-20260216110753-a700c951377c h1:rIVWwnNxHYu9aZhHkptXlNYTBJbY4ccaIAYjztVeaDc=
|
||||
go.gearno.de/x/ref v0.0.0-20260216110753-a700c951377c/go.mod h1:k3GtgnI5X9dl8FlqaNYkCkil7/iACQdFOromU/H4u6I=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
|
||||
@@ -217,6 +217,7 @@ const (
|
||||
subjectTrustCenterAccess = "Compliance Page Access Invitation - %s"
|
||||
subjectTrustCenterDocumentAccessRejected = "Compliance Page Document Access Rejected - %s"
|
||||
subjectMagicLink = "Connect to %s"
|
||||
subjectElectronicSignatureCertificate = "Your signed %s - Certificate of Completion"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -238,6 +239,8 @@ var (
|
||||
trustCenterDocumentAccessRejectedTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/trust-center-document-access-rejected.txt.tmpl"))
|
||||
magicLinkHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/magic-link.html.tmpl"))
|
||||
magicLinkTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/magic-link.txt.tmpl"))
|
||||
electronicSignatureCertificateHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/electronic-signature-certificate.html.tmpl"))
|
||||
electronicSignatureCertificateTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/electronic-signature-certificate.txt.tmpl"))
|
||||
)
|
||||
|
||||
func (p *Presenter) getCommonVariables(ctx context.Context) (*CommonVariables, error) {
|
||||
@@ -460,6 +463,26 @@ func (p *Presenter) RenderMagicLink(ctx context.Context, magicLinkUrlPath string
|
||||
return fmt.Sprintf(subjectMagicLink, organizationName), textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func (p *Presenter) RenderElectronicSignatureCertificate(ctx context.Context, signerName string, documentType string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
vars, err := p.getCommonVariables(ctx)
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
||||
}
|
||||
|
||||
data := struct {
|
||||
*CommonVariables
|
||||
SignerName string
|
||||
DocumentType string
|
||||
}{
|
||||
CommonVariables: vars,
|
||||
SignerName: signerName,
|
||||
DocumentType: documentType,
|
||||
}
|
||||
|
||||
textBody, htmlBody, err = renderEmail(electronicSignatureCertificateTextTemplate, electronicSignatureCertificateHTMLTemplate, data)
|
||||
return fmt.Sprintf(subjectElectronicSignatureCertificate, documentType), textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func renderEmail(textTemplate *texttemplate.Template, htmlTemplate *htmltemplate.Template, data any) (textBody string, htmlBody *string, err error) {
|
||||
var textBuf bytes.Buffer
|
||||
if err := textTemplate.Execute(&textBuf, data); err != nil {
|
||||
|
||||
@@ -12,6 +12,7 @@ import Invitation from "../src/Invitation";
|
||||
import PasswordReset from "../src/PasswordReset";
|
||||
import TrustCenterAccess from "../src/TrustCenterAccess";
|
||||
import TrustCenterDocumentAccessRejected from "../src/TrustCenterDocumentAccessRejected";
|
||||
import ElectronicSignatureCertificate from "../src/ElectronicSignatureCertificate";
|
||||
import MagicLink from "../src/MagicLink";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -59,6 +60,10 @@ const templates: TemplateConfig[] = [
|
||||
name: "magic-link",
|
||||
render: () => MagicLink(),
|
||||
},
|
||||
{
|
||||
name: "electronic-signature-certificate",
|
||||
render: () => ElectronicSignatureCertificate(),
|
||||
},
|
||||
];
|
||||
|
||||
async function build() {
|
||||
|
||||
31
packages/emails/src/ElectronicSignatureCertificate.tsx
Normal file
31
packages/emails/src/ElectronicSignatureCertificate.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Text } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import EmailLayout, {
|
||||
bodyText,
|
||||
footerText,
|
||||
} from "./components/EmailLayout";
|
||||
|
||||
export const ElectronicSignatureCertificate = () => {
|
||||
return (
|
||||
<EmailLayout
|
||||
subject={`Your signed ${"{{.DocumentType}}"} — Certificate of Completion`}
|
||||
>
|
||||
<Text style={bodyText}>
|
||||
Your <strong>{"{{.DocumentType}}"}</strong> has been signed
|
||||
electronically. A Certificate of Completion is attached to this email
|
||||
as a PDF document.
|
||||
</Text>
|
||||
|
||||
<Text style={bodyText}>
|
||||
The certificate contains a complete record of the signing event,
|
||||
including the integrity seal, timestamp, and full audit trail.
|
||||
</Text>
|
||||
|
||||
<Text style={footerText}>
|
||||
If you have any questions, please contact support.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ElectronicSignatureCertificate;
|
||||
@@ -0,0 +1,12 @@
|
||||
{{.SenderCompanyName}}
|
||||
|
||||
Hi {{.RecipientFullName}},
|
||||
|
||||
Your {{.DocumentType}} has been signed electronically. A Certificate of Completion is attached to this email as a PDF document.
|
||||
|
||||
The certificate contains a complete record of the signing event, including the integrity seal, timestamp, and audit trail.
|
||||
|
||||
If you have any questions, please contact support.
|
||||
|
||||
{{.SenderCompanyHeadquarterAddress}}
|
||||
Powered By Probo
|
||||
424
pkg/coredata/electronic_signature.go
Normal file
424
pkg/coredata/electronic_signature.go
Normal file
@@ -0,0 +1,424 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.gearno.de/x/ref"
|
||||
"go.probo.inc/probo/pkg/crypto/hash"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type ElectronicSignature struct {
|
||||
ID gid.GID `db:"id"`
|
||||
TenantID gid.TenantID `db:"tenant_id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
Status ElectronicSignatureStatus `db:"status"`
|
||||
DocumentType ElectronicSignatureDocumentType `db:"document_type"`
|
||||
FileID gid.GID `db:"file_id"`
|
||||
SignerEmail string `db:"signer_email"`
|
||||
ConsentText string `db:"consent_text"`
|
||||
SignerFullName *string `db:"signer_full_name"`
|
||||
SignerIPAddress *string `db:"signer_ip_address"`
|
||||
SignerUserAgent *string `db:"signer_user_agent"`
|
||||
FileHash *string `db:"file_hash"`
|
||||
Seal *string `db:"seal"`
|
||||
SealVersion int `db:"seal_version"`
|
||||
TSAToken []byte `db:"tsa_token"`
|
||||
SignedAt *time.Time `db:"signed_at"`
|
||||
CertificateFileID *gid.GID `db:"certificate_file_id"`
|
||||
CertificateProcessingStartedAt *time.Time `db:"certificate_processing_started_at"`
|
||||
AttemptCount int `db:"attempt_count"`
|
||||
MaxAttempts int `db:"max_attempts"`
|
||||
LastAttemptedAt *time.Time `db:"last_attempted_at"`
|
||||
LastError *string `db:"last_error"`
|
||||
ProcessingStartedAt *time.Time `db:"processing_started_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
func (es *ElectronicSignature) NewEvent(
|
||||
eventType ElectronicSignatureEventType,
|
||||
eventSource ElectronicSignatureEventSource,
|
||||
) ElectronicSignatureEvent {
|
||||
now := time.Now()
|
||||
|
||||
return ElectronicSignatureEvent{
|
||||
ID: gid.New(es.ID.TenantID(), ElectronicSignatureEventEntityType),
|
||||
ElectronicSignatureID: es.ID,
|
||||
EventType: eventType,
|
||||
EventSource: eventSource,
|
||||
ActorEmail: es.SignerEmail,
|
||||
ActorIPAddress: ref.UnrefOrZero(es.SignerIPAddress),
|
||||
ActorUserAgent: ref.UnrefOrZero(es.SignerUserAgent),
|
||||
OccurredAt: now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (es *ElectronicSignature) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO electronic_signatures (
|
||||
id, tenant_id, organization_id, status, document_type, file_id,
|
||||
signer_email, consent_text, seal_version, attempt_count, max_attempts,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
@id, @tenant_id, @organization_id, @status, @document_type, @file_id,
|
||||
@signer_email, @consent_text, @seal_version, @attempt_count, @max_attempts,
|
||||
@created_at, @updated_at
|
||||
)
|
||||
`
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": es.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": es.OrganizationID,
|
||||
"status": es.Status,
|
||||
"document_type": es.DocumentType,
|
||||
"file_id": es.FileID,
|
||||
"signer_email": es.SignerEmail,
|
||||
"consent_text": es.ConsentText,
|
||||
"seal_version": es.SealVersion,
|
||||
"attempt_count": es.AttemptCount,
|
||||
"max_attempts": es.MaxAttempts,
|
||||
"created_at": es.CreatedAt,
|
||||
"updated_at": es.UpdatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert electronic signature: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (es *ElectronicSignature) Update(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE electronic_signatures SET
|
||||
status = @status,
|
||||
signer_full_name = @signer_full_name,
|
||||
signer_ip_address = @signer_ip_address,
|
||||
signer_user_agent = @signer_user_agent,
|
||||
file_hash = @file_hash,
|
||||
seal = @seal,
|
||||
seal_version = @seal_version,
|
||||
tsa_token = @tsa_token,
|
||||
signed_at = @signed_at,
|
||||
certificate_file_id = @certificate_file_id,
|
||||
certificate_processing_started_at = @certificate_processing_started_at,
|
||||
attempt_count = @attempt_count,
|
||||
max_attempts = @max_attempts,
|
||||
last_attempted_at = @last_attempted_at,
|
||||
last_error = @last_error,
|
||||
processing_started_at = @processing_started_at,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": es.ID,
|
||||
"status": es.Status,
|
||||
"signer_full_name": es.SignerFullName,
|
||||
"signer_ip_address": es.SignerIPAddress,
|
||||
"signer_user_agent": es.SignerUserAgent,
|
||||
"file_hash": es.FileHash,
|
||||
"seal": es.Seal,
|
||||
"seal_version": es.SealVersion,
|
||||
"tsa_token": es.TSAToken,
|
||||
"signed_at": es.SignedAt,
|
||||
"certificate_file_id": es.CertificateFileID,
|
||||
"certificate_processing_started_at": es.CertificateProcessingStartedAt,
|
||||
"attempt_count": es.AttemptCount,
|
||||
"max_attempts": es.MaxAttempts,
|
||||
"last_attempted_at": es.LastAttemptedAt,
|
||||
"last_error": es.LastError,
|
||||
"processing_started_at": es.ProcessingStartedAt,
|
||||
"updated_at": es.UpdatedAt,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update electronic signature: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (es *ElectronicSignature) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
id gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id, tenant_id, organization_id, status, document_type, file_id,
|
||||
signer_email, consent_text, signer_full_name, signer_ip_address,
|
||||
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
|
||||
certificate_file_id, certificate_processing_started_at,
|
||||
attempt_count, max_attempts, last_attempted_at, last_error,
|
||||
processing_started_at, created_at, updated_at
|
||||
FROM electronic_signatures
|
||||
WHERE %s AND id = @id
|
||||
LIMIT 1
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"id": id}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query electronic signature: %w", err)
|
||||
}
|
||||
|
||||
sig, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ElectronicSignature])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect electronic signature: %w", err)
|
||||
}
|
||||
|
||||
*es = sig
|
||||
return nil
|
||||
}
|
||||
|
||||
func (es *ElectronicSignature) LoadByOrgEmailAndDocType(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
orgID gid.GID,
|
||||
email string,
|
||||
docType ElectronicSignatureDocumentType,
|
||||
fileID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id, tenant_id, organization_id, status, document_type, file_id,
|
||||
signer_email, consent_text, signer_full_name, signer_ip_address,
|
||||
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
|
||||
certificate_file_id, certificate_processing_started_at,
|
||||
attempt_count, max_attempts, last_attempted_at, last_error,
|
||||
processing_started_at, created_at, updated_at
|
||||
FROM electronic_signatures
|
||||
WHERE %s
|
||||
AND organization_id = @organization_id
|
||||
AND signer_email = @signer_email
|
||||
AND document_type = @document_type
|
||||
AND file_id = @file_id
|
||||
LIMIT 1
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"organization_id": orgID,
|
||||
"signer_email": email,
|
||||
"document_type": docType,
|
||||
"file_id": fileID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query electronic signature: %w", err)
|
||||
}
|
||||
|
||||
sig, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ElectronicSignature])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect electronic signature: %w", err)
|
||||
}
|
||||
|
||||
*es = sig
|
||||
return nil
|
||||
}
|
||||
|
||||
func (es *ElectronicSignature) LoadNextAcceptedForUpdateSkipLocked(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id, tenant_id, organization_id, status, document_type, file_id,
|
||||
signer_email, consent_text, signer_full_name, signer_ip_address,
|
||||
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
|
||||
certificate_file_id, certificate_processing_started_at,
|
||||
attempt_count, max_attempts, last_attempted_at, last_error,
|
||||
processing_started_at, created_at, updated_at
|
||||
FROM electronic_signatures
|
||||
WHERE status = 'ACCEPTED' AND attempt_count < max_attempts
|
||||
ORDER BY updated_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`
|
||||
|
||||
rows, err := conn.Query(ctx, q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query accepted signatures: %w", err)
|
||||
}
|
||||
|
||||
sig, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ElectronicSignature])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect electronic signature: %w", err)
|
||||
}
|
||||
|
||||
*es = sig
|
||||
return nil
|
||||
}
|
||||
|
||||
func (es *ElectronicSignature) LoadNextCompletedWithoutCertificateForUpdate(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id, tenant_id, organization_id, status, document_type, file_id,
|
||||
signer_email, consent_text, signer_full_name, signer_ip_address,
|
||||
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
|
||||
certificate_file_id, certificate_processing_started_at,
|
||||
attempt_count, max_attempts, last_attempted_at, last_error,
|
||||
processing_started_at, created_at, updated_at
|
||||
FROM electronic_signatures
|
||||
WHERE status = 'COMPLETED'
|
||||
AND certificate_file_id IS NULL
|
||||
AND certificate_processing_started_at IS NULL
|
||||
ORDER BY signed_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`
|
||||
|
||||
rows, err := conn.Query(ctx, q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query completed signatures: %w", err)
|
||||
}
|
||||
|
||||
sig, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ElectronicSignature])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect electronic signature: %w", err)
|
||||
}
|
||||
|
||||
*es = sig
|
||||
return nil
|
||||
}
|
||||
|
||||
func ResetStaleProcessingSignatures(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
staleAfter time.Duration,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE electronic_signatures
|
||||
SET status = 'ACCEPTED', processing_started_at = NULL, updated_at = NOW()
|
||||
WHERE status = 'PROCESSING'
|
||||
AND processing_started_at < NOW() - $1::interval
|
||||
`
|
||||
_, err := conn.Exec(ctx, q, staleAfter.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot reset stale processing signatures: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (es *ElectronicSignature) ComputeSeal(version int) (string, error) {
|
||||
switch version {
|
||||
case 1:
|
||||
return es.computeSealV1()
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported seal version %d", version)
|
||||
}
|
||||
}
|
||||
|
||||
func (es *ElectronicSignature) computeSealV1() (string, error) {
|
||||
if es.SignedAt == nil {
|
||||
return "", fmt.Errorf("signed_at must not be nil")
|
||||
}
|
||||
|
||||
fields := []string{
|
||||
es.ID.String(),
|
||||
es.OrganizationID.String(),
|
||||
es.DocumentType.String(),
|
||||
es.FileID.String(),
|
||||
ref.UnrefOrZero(es.FileHash),
|
||||
ref.UnrefOrZero(es.SignerFullName),
|
||||
strings.ToLower(es.SignerEmail),
|
||||
ref.UnrefOrZero(es.SignerIPAddress),
|
||||
ref.UnrefOrZero(es.SignerUserAgent),
|
||||
es.ConsentText,
|
||||
es.SignedAt.UTC().Truncate(time.Microsecond).Format(time.RFC3339Nano),
|
||||
}
|
||||
|
||||
for i, f := range fields {
|
||||
if f == "" {
|
||||
return "", fmt.Errorf("seal field %d must not be empty", i)
|
||||
}
|
||||
if strings.Contains(f, "\n") {
|
||||
return "", fmt.Errorf("seal field %d must not contain newline", i)
|
||||
}
|
||||
}
|
||||
|
||||
input := strings.Join(fields, "\n")
|
||||
return hash.SHA256Hex([]byte(input)), nil
|
||||
}
|
||||
|
||||
func ResetStaleCertificateProcessing(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
staleAfter time.Duration,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE electronic_signatures
|
||||
SET certificate_processing_started_at = NULL, updated_at = NOW()
|
||||
WHERE status = 'COMPLETED'
|
||||
AND certificate_file_id IS NULL
|
||||
AND certificate_processing_started_at IS NOT NULL
|
||||
AND certificate_processing_started_at < NOW() - $1::interval
|
||||
`
|
||||
_, err := conn.Exec(ctx, q, staleAfter.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot reset stale certificate processing: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
145
pkg/coredata/electronic_signature_document_type.go
Normal file
145
pkg/coredata/electronic_signature_document_type.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type (
|
||||
ElectronicSignatureDocumentType string
|
||||
)
|
||||
|
||||
const (
|
||||
ElectronicSignatureDocumentTypeNDA ElectronicSignatureDocumentType = "NDA"
|
||||
ElectronicSignatureDocumentTypeDPA ElectronicSignatureDocumentType = "DPA"
|
||||
ElectronicSignatureDocumentTypeMSA ElectronicSignatureDocumentType = "MSA"
|
||||
ElectronicSignatureDocumentTypeSOW ElectronicSignatureDocumentType = "SOW"
|
||||
ElectronicSignatureDocumentTypeSLA ElectronicSignatureDocumentType = "SLA"
|
||||
ElectronicSignatureDocumentTypeTOS ElectronicSignatureDocumentType = "TOS"
|
||||
ElectronicSignatureDocumentTypePrivacyPolicy ElectronicSignatureDocumentType = "PRIVACY_POLICY"
|
||||
ElectronicSignatureDocumentTypeOther ElectronicSignatureDocumentType = "OTHER"
|
||||
|
||||
ESignProcessConsentText = "By typing my full name and clicking Accept, I consent to sign this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature."
|
||||
)
|
||||
|
||||
func ElectronicSignatureDocumentTypes() []ElectronicSignatureDocumentType {
|
||||
return []ElectronicSignatureDocumentType{
|
||||
ElectronicSignatureDocumentTypeNDA,
|
||||
ElectronicSignatureDocumentTypeDPA,
|
||||
ElectronicSignatureDocumentTypeMSA,
|
||||
ElectronicSignatureDocumentTypeSOW,
|
||||
ElectronicSignatureDocumentTypeSLA,
|
||||
ElectronicSignatureDocumentTypeTOS,
|
||||
ElectronicSignatureDocumentTypePrivacyPolicy,
|
||||
ElectronicSignatureDocumentTypeOther,
|
||||
}
|
||||
}
|
||||
|
||||
func (dt ElectronicSignatureDocumentType) MarshalText() ([]byte, error) {
|
||||
return []byte(dt.String()), nil
|
||||
}
|
||||
|
||||
func (dt *ElectronicSignatureDocumentType) UnmarshalText(data []byte) error {
|
||||
val := string(data)
|
||||
|
||||
switch val {
|
||||
case ElectronicSignatureDocumentTypeNDA.String():
|
||||
*dt = ElectronicSignatureDocumentTypeNDA
|
||||
case ElectronicSignatureDocumentTypeDPA.String():
|
||||
*dt = ElectronicSignatureDocumentTypeDPA
|
||||
case ElectronicSignatureDocumentTypeMSA.String():
|
||||
*dt = ElectronicSignatureDocumentTypeMSA
|
||||
case ElectronicSignatureDocumentTypeSOW.String():
|
||||
*dt = ElectronicSignatureDocumentTypeSOW
|
||||
case ElectronicSignatureDocumentTypeSLA.String():
|
||||
*dt = ElectronicSignatureDocumentTypeSLA
|
||||
case ElectronicSignatureDocumentTypeTOS.String():
|
||||
*dt = ElectronicSignatureDocumentTypeTOS
|
||||
case ElectronicSignatureDocumentTypePrivacyPolicy.String():
|
||||
*dt = ElectronicSignatureDocumentTypePrivacyPolicy
|
||||
case ElectronicSignatureDocumentTypeOther.String():
|
||||
*dt = ElectronicSignatureDocumentTypeOther
|
||||
default:
|
||||
return fmt.Errorf("invalid ElectronicSignatureDocumentType value: %q", val)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dt ElectronicSignatureDocumentType) String() string {
|
||||
return string(dt)
|
||||
}
|
||||
|
||||
func (dt *ElectronicSignatureDocumentType) Scan(value any) error {
|
||||
val, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid scan source for ElectronicSignatureDocumentType, expected string got %T", value)
|
||||
}
|
||||
|
||||
return dt.UnmarshalText([]byte(val))
|
||||
}
|
||||
|
||||
func (dt ElectronicSignatureDocumentType) Value() (driver.Value, error) {
|
||||
return dt.String(), nil
|
||||
}
|
||||
|
||||
func (dt ElectronicSignatureDocumentType) DisplayName() string {
|
||||
switch dt {
|
||||
case ElectronicSignatureDocumentTypeNDA:
|
||||
return "Non-Disclosure Agreement"
|
||||
case ElectronicSignatureDocumentTypeDPA:
|
||||
return "Data Processing Agreement"
|
||||
case ElectronicSignatureDocumentTypeMSA:
|
||||
return "Master Service Agreement"
|
||||
case ElectronicSignatureDocumentTypeSOW:
|
||||
return "Statement of Work"
|
||||
case ElectronicSignatureDocumentTypeSLA:
|
||||
return "Service Level Agreement"
|
||||
case ElectronicSignatureDocumentTypeTOS:
|
||||
return "Terms of Service"
|
||||
case ElectronicSignatureDocumentTypePrivacyPolicy:
|
||||
return "Privacy Policy"
|
||||
default:
|
||||
return string(dt)
|
||||
}
|
||||
}
|
||||
|
||||
func (dt ElectronicSignatureDocumentType) ConsentText() (string, error) {
|
||||
var docAgreement string
|
||||
switch dt {
|
||||
case ElectronicSignatureDocumentTypeNDA:
|
||||
docAgreement = "I agree to the terms of this Non-Disclosure Agreement."
|
||||
case ElectronicSignatureDocumentTypeDPA:
|
||||
docAgreement = "I agree to the terms of this Data Processing Agreement."
|
||||
case ElectronicSignatureDocumentTypeMSA:
|
||||
docAgreement = "I agree to the terms of this Master Service Agreement."
|
||||
case ElectronicSignatureDocumentTypeSOW:
|
||||
docAgreement = "I agree to the terms of this Statement of Work."
|
||||
case ElectronicSignatureDocumentTypeSLA:
|
||||
docAgreement = "I agree to the terms of this Service Level Agreement."
|
||||
case ElectronicSignatureDocumentTypeTOS:
|
||||
docAgreement = "I agree to these Terms of Service."
|
||||
case ElectronicSignatureDocumentTypePrivacyPolicy:
|
||||
docAgreement = "I agree to this Privacy Policy."
|
||||
case ElectronicSignatureDocumentTypeOther:
|
||||
return "", fmt.Errorf("document type OTHER requires explicit consent text")
|
||||
default:
|
||||
return "", fmt.Errorf("unknown document type %q", dt)
|
||||
}
|
||||
|
||||
return docAgreement + " " + ESignProcessConsentText, nil
|
||||
}
|
||||
117
pkg/coredata/electronic_signature_event.go
Normal file
117
pkg/coredata/electronic_signature_event.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
ElectronicSignatureEvent struct {
|
||||
ID gid.GID `db:"id"`
|
||||
TenantID gid.TenantID `db:"tenant_id"`
|
||||
ElectronicSignatureID gid.GID `db:"electronic_signature_id"`
|
||||
EventType ElectronicSignatureEventType `db:"event_type"`
|
||||
EventSource ElectronicSignatureEventSource `db:"event_source"`
|
||||
ActorEmail string `db:"actor_email"`
|
||||
ActorIPAddress string `db:"actor_ip_address"`
|
||||
ActorUserAgent string `db:"actor_user_agent"`
|
||||
OccurredAt time.Time `db:"occurred_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
ElectronicSignatureEvents []*ElectronicSignatureEvent
|
||||
)
|
||||
|
||||
func (e *ElectronicSignatureEvent) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO electronic_signature_events (
|
||||
id, tenant_id, electronic_signature_id, event_type, event_source,
|
||||
actor_email, actor_ip_address, actor_user_agent,
|
||||
occurred_at, created_at
|
||||
) VALUES (
|
||||
@id, @tenant_id, @electronic_signature_id, @event_type, @event_source,
|
||||
@actor_email, @actor_ip_address, @actor_user_agent,
|
||||
@occurred_at, @created_at
|
||||
)
|
||||
`
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": e.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"electronic_signature_id": e.ElectronicSignatureID,
|
||||
"event_type": e.EventType,
|
||||
"event_source": e.EventSource,
|
||||
"actor_email": e.ActorEmail,
|
||||
"actor_ip_address": e.ActorIPAddress,
|
||||
"actor_user_agent": e.ActorUserAgent,
|
||||
"occurred_at": e.OccurredAt,
|
||||
"created_at": e.CreatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert electronic signature event: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (es *ElectronicSignatureEvents) LoadBySignatureID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
sigID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id, tenant_id, electronic_signature_id, event_type, event_source,
|
||||
actor_email, actor_ip_address, actor_user_agent,
|
||||
occurred_at, created_at
|
||||
FROM electronic_signature_events
|
||||
WHERE %s AND electronic_signature_id = @electronic_signature_id
|
||||
ORDER BY occurred_at ASC
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"electronic_signature_id": sigID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query electronic signature events: %w", err)
|
||||
}
|
||||
|
||||
events, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ElectronicSignatureEvent])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect electronic signature events: %w", err)
|
||||
}
|
||||
|
||||
*es = events
|
||||
|
||||
return nil
|
||||
}
|
||||
65
pkg/coredata/electronic_signature_event_source.go
Normal file
65
pkg/coredata/electronic_signature_event_source.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type (
|
||||
ElectronicSignatureEventSource string
|
||||
)
|
||||
|
||||
const (
|
||||
ElectronicSignatureEventSourceClient ElectronicSignatureEventSource = "CLIENT"
|
||||
ElectronicSignatureEventSourceServer ElectronicSignatureEventSource = "SERVER"
|
||||
)
|
||||
|
||||
func (s ElectronicSignatureEventSource) MarshalText() ([]byte, error) {
|
||||
return []byte(s.String()), nil
|
||||
}
|
||||
|
||||
func (s *ElectronicSignatureEventSource) UnmarshalText(data []byte) error {
|
||||
val := string(data)
|
||||
|
||||
switch val {
|
||||
case ElectronicSignatureEventSourceClient.String():
|
||||
*s = ElectronicSignatureEventSourceClient
|
||||
case ElectronicSignatureEventSourceServer.String():
|
||||
*s = ElectronicSignatureEventSourceServer
|
||||
default:
|
||||
return fmt.Errorf("invalid ElectronicSignatureEventSource value: %q", val)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s ElectronicSignatureEventSource) String() string {
|
||||
return string(s)
|
||||
}
|
||||
|
||||
func (s *ElectronicSignatureEventSource) Scan(value any) error {
|
||||
val, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid scan source for ElectronicSignatureEventSource, expected string got %T", value)
|
||||
}
|
||||
|
||||
return s.UnmarshalText([]byte(val))
|
||||
}
|
||||
|
||||
func (s ElectronicSignatureEventSource) Value() (driver.Value, error) {
|
||||
return s.String(), nil
|
||||
}
|
||||
86
pkg/coredata/electronic_signature_event_type.go
Normal file
86
pkg/coredata/electronic_signature_event_type.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type (
|
||||
ElectronicSignatureEventType string
|
||||
)
|
||||
|
||||
const (
|
||||
ElectronicSignatureEventTypeDocumentViewed ElectronicSignatureEventType = "DOCUMENT_VIEWED"
|
||||
ElectronicSignatureEventTypeConsentGiven ElectronicSignatureEventType = "CONSENT_GIVEN"
|
||||
ElectronicSignatureEventTypeFullNameTyped ElectronicSignatureEventType = "FULL_NAME_TYPED"
|
||||
ElectronicSignatureEventTypeSignatureAccepted ElectronicSignatureEventType = "SIGNATURE_ACCEPTED"
|
||||
ElectronicSignatureEventTypeSignatureCompleted ElectronicSignatureEventType = "SIGNATURE_COMPLETED"
|
||||
ElectronicSignatureEventTypeSealComputed ElectronicSignatureEventType = "SEAL_COMPUTED"
|
||||
ElectronicSignatureEventTypeTimestampRequested ElectronicSignatureEventType = "TIMESTAMP_REQUESTED"
|
||||
ElectronicSignatureEventTypeCertificateGenerated ElectronicSignatureEventType = "CERTIFICATE_GENERATED"
|
||||
ElectronicSignatureEventTypeProcessingError ElectronicSignatureEventType = "PROCESSING_ERROR"
|
||||
)
|
||||
|
||||
func (t ElectronicSignatureEventType) MarshalText() ([]byte, error) {
|
||||
return []byte(t.String()), nil
|
||||
}
|
||||
|
||||
func (t *ElectronicSignatureEventType) UnmarshalText(data []byte) error {
|
||||
val := string(data)
|
||||
|
||||
switch val {
|
||||
case ElectronicSignatureEventTypeDocumentViewed.String():
|
||||
*t = ElectronicSignatureEventTypeDocumentViewed
|
||||
case ElectronicSignatureEventTypeConsentGiven.String():
|
||||
*t = ElectronicSignatureEventTypeConsentGiven
|
||||
case ElectronicSignatureEventTypeFullNameTyped.String():
|
||||
*t = ElectronicSignatureEventTypeFullNameTyped
|
||||
case ElectronicSignatureEventTypeSignatureAccepted.String():
|
||||
*t = ElectronicSignatureEventTypeSignatureAccepted
|
||||
case ElectronicSignatureEventTypeSignatureCompleted.String():
|
||||
*t = ElectronicSignatureEventTypeSignatureCompleted
|
||||
case ElectronicSignatureEventTypeSealComputed.String():
|
||||
*t = ElectronicSignatureEventTypeSealComputed
|
||||
case ElectronicSignatureEventTypeTimestampRequested.String():
|
||||
*t = ElectronicSignatureEventTypeTimestampRequested
|
||||
case ElectronicSignatureEventTypeCertificateGenerated.String():
|
||||
*t = ElectronicSignatureEventTypeCertificateGenerated
|
||||
case ElectronicSignatureEventTypeProcessingError.String():
|
||||
*t = ElectronicSignatureEventTypeProcessingError
|
||||
default:
|
||||
return fmt.Errorf("invalid ElectronicSignatureEventType value: %q", val)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t ElectronicSignatureEventType) String() string {
|
||||
return string(t)
|
||||
}
|
||||
|
||||
func (t *ElectronicSignatureEventType) Scan(value any) error {
|
||||
val, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid scan source for ElectronicSignatureEventType, expected string got %T", value)
|
||||
}
|
||||
|
||||
return t.UnmarshalText([]byte(val))
|
||||
}
|
||||
|
||||
func (t ElectronicSignatureEventType) Value() (driver.Value, error) {
|
||||
return t.String(), nil
|
||||
}
|
||||
74
pkg/coredata/electronic_signature_status.go
Normal file
74
pkg/coredata/electronic_signature_status.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type (
|
||||
ElectronicSignatureStatus string
|
||||
)
|
||||
|
||||
const (
|
||||
ElectronicSignatureStatusPending ElectronicSignatureStatus = "PENDING"
|
||||
ElectronicSignatureStatusAccepted ElectronicSignatureStatus = "ACCEPTED"
|
||||
ElectronicSignatureStatusProcessing ElectronicSignatureStatus = "PROCESSING"
|
||||
ElectronicSignatureStatusCompleted ElectronicSignatureStatus = "COMPLETED"
|
||||
ElectronicSignatureStatusFailed ElectronicSignatureStatus = "FAILED"
|
||||
)
|
||||
|
||||
func (s ElectronicSignatureStatus) MarshalText() ([]byte, error) {
|
||||
return []byte(s.String()), nil
|
||||
}
|
||||
|
||||
func (s *ElectronicSignatureStatus) UnmarshalText(data []byte) error {
|
||||
val := string(data)
|
||||
|
||||
switch val {
|
||||
case ElectronicSignatureStatusPending.String():
|
||||
*s = ElectronicSignatureStatusPending
|
||||
case ElectronicSignatureStatusAccepted.String():
|
||||
*s = ElectronicSignatureStatusAccepted
|
||||
case ElectronicSignatureStatusProcessing.String():
|
||||
*s = ElectronicSignatureStatusProcessing
|
||||
case ElectronicSignatureStatusCompleted.String():
|
||||
*s = ElectronicSignatureStatusCompleted
|
||||
case ElectronicSignatureStatusFailed.String():
|
||||
*s = ElectronicSignatureStatusFailed
|
||||
default:
|
||||
return fmt.Errorf("invalid ElectronicSignatureStatus value: %q", val)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s ElectronicSignatureStatus) String() string {
|
||||
return string(s)
|
||||
}
|
||||
|
||||
func (s *ElectronicSignatureStatus) Scan(value any) error {
|
||||
val, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid scan source for ElectronicSignatureStatus, expected string got %T", value)
|
||||
}
|
||||
|
||||
return s.UnmarshalText([]byte(val))
|
||||
}
|
||||
|
||||
func (s ElectronicSignatureStatus) Value() (driver.Value, error) {
|
||||
return s.String(), nil
|
||||
}
|
||||
105
pkg/coredata/email_attachment.go
Normal file
105
pkg/coredata/email_attachment.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
EmailAttachment struct {
|
||||
ID gid.GID `db:"id"`
|
||||
EmailID gid.GID `db:"email_id"`
|
||||
FileID gid.GID `db:"file_id"`
|
||||
Filename string `db:"filename"`
|
||||
// TODO (to drop)
|
||||
ContentType string `db:"content_type"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
EmailAttachments []*EmailAttachment
|
||||
)
|
||||
|
||||
func NewEmailAttachment(emailID, fileID gid.GID, filename, contentType string) *EmailAttachment {
|
||||
return &EmailAttachment{
|
||||
ID: gid.New(gid.NilTenant, EmailAttachmentEntityType),
|
||||
EmailID: emailID,
|
||||
FileID: fileID,
|
||||
Filename: filename,
|
||||
ContentType: contentType,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *EmailAttachment) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO email_attachments (id, email_id, file_id, filename, content_type, created_at)
|
||||
VALUES (@id, @email_id, @file_id, @filename, @content_type, @created_at)
|
||||
`
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": a.ID,
|
||||
"email_id": a.EmailID,
|
||||
"file_id": a.FileID,
|
||||
"filename": a.Filename,
|
||||
"content_type": a.ContentType,
|
||||
"created_at": a.CreatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert email attachment: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *EmailAttachments) LoadByEmailID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
emailID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT id, email_id, file_id, filename, content_type, created_at
|
||||
FROM email_attachments
|
||||
WHERE email_id = @email_id
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
args := pgx.StrictNamedArgs{
|
||||
"email_id": emailID,
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query email attachments: %w", err)
|
||||
}
|
||||
|
||||
attachments, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[EmailAttachment])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect email attachments: %w", err)
|
||||
}
|
||||
|
||||
*a = attachments
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -82,6 +82,9 @@ const (
|
||||
WebhookSubscriptionEntityType uint16 = 56
|
||||
WebhookDataEntityType uint16 = 57
|
||||
WebhookEventEntityType uint16 = 58
|
||||
ElectronicSignatureEntityType uint16 = 59
|
||||
ElectronicSignatureEventEntityType uint16 = 60
|
||||
EmailAttachmentEntityType uint16 = 61
|
||||
)
|
||||
|
||||
func NewEntityFromID(id gid.GID) (any, bool) {
|
||||
@@ -200,6 +203,12 @@ func NewEntityFromID(id gid.GID) (any, bool) {
|
||||
return &WebhookData{ID: id}, true
|
||||
case WebhookEventEntityType:
|
||||
return &WebhookEvent{ID: id}, true
|
||||
case ElectronicSignatureEntityType:
|
||||
return &ElectronicSignature{ID: id}, true
|
||||
case ElectronicSignatureEventEntityType:
|
||||
return &ElectronicSignatureEvent{ID: id}, true
|
||||
case EmailAttachmentEntityType:
|
||||
return &EmailAttachment{ID: id}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
|
||||
94
pkg/coredata/migrations/20260213T150000Z.sql
Normal file
94
pkg/coredata/migrations/20260213T150000Z.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
-- Electronic signature enums
|
||||
CREATE TYPE electronic_signature_document_type AS ENUM (
|
||||
'NDA', 'DPA', 'MSA', 'SOW', 'SLA', 'TOS', 'PRIVACY_POLICY', 'OTHER'
|
||||
);
|
||||
|
||||
CREATE TYPE electronic_signature_status AS ENUM (
|
||||
'PENDING',
|
||||
'ACCEPTED',
|
||||
'PROCESSING',
|
||||
'COMPLETED',
|
||||
'FAILED'
|
||||
);
|
||||
|
||||
CREATE TYPE electronic_signature_event_type AS ENUM (
|
||||
'DOCUMENT_VIEWED', 'CONSENT_GIVEN', 'FULL_NAME_TYPED',
|
||||
'SIGNATURE_ACCEPTED',
|
||||
'SIGNATURE_COMPLETED', 'SEAL_COMPUTED', 'TIMESTAMP_REQUESTED',
|
||||
'CERTIFICATE_GENERATED',
|
||||
'PROCESSING_ERROR'
|
||||
);
|
||||
|
||||
CREATE TYPE electronic_signature_event_source AS ENUM ('CLIENT', 'SERVER');
|
||||
|
||||
-- Electronic signatures table
|
||||
CREATE TABLE electronic_signatures (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
status electronic_signature_status NOT NULL DEFAULT 'PENDING',
|
||||
document_type electronic_signature_document_type NOT NULL,
|
||||
file_id TEXT NOT NULL REFERENCES files(id),
|
||||
signer_email CITEXT NOT NULL,
|
||||
|
||||
-- Set at creation time (PENDING):
|
||||
consent_text TEXT NOT NULL,
|
||||
|
||||
-- Set when status transitions to ACCEPTED (signer submits):
|
||||
signer_full_name TEXT,
|
||||
signer_ip_address TEXT,
|
||||
signer_user_agent TEXT,
|
||||
|
||||
-- Set when status transitions to COMPLETED (worker finishes):
|
||||
file_hash TEXT,
|
||||
seal TEXT,
|
||||
seal_version INT NOT NULL DEFAULT 1,
|
||||
tsa_token BYTEA,
|
||||
signed_at TIMESTAMPTZ,
|
||||
|
||||
-- Set by certificate worker after COMPLETED:
|
||||
certificate_file_id TEXT REFERENCES files(id),
|
||||
certificate_processing_started_at TIMESTAMPTZ,
|
||||
|
||||
-- Async processing state:
|
||||
attempt_count INT NOT NULL DEFAULT 0,
|
||||
max_attempts INT NOT NULL DEFAULT 10,
|
||||
last_attempted_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
processing_started_at TIMESTAMPTZ,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
|
||||
-- file_id in the constraint allows re-signing when the org replaces the NDA file.
|
||||
UNIQUE(organization_id, signer_email, document_type, file_id)
|
||||
);
|
||||
|
||||
-- Electronic signature events table
|
||||
CREATE TABLE electronic_signature_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
electronic_signature_id TEXT NOT NULL REFERENCES electronic_signatures(id) ON DELETE CASCADE,
|
||||
event_type electronic_signature_event_type NOT NULL,
|
||||
event_source electronic_signature_event_source NOT NULL,
|
||||
actor_email CITEXT NOT NULL,
|
||||
actor_ip_address TEXT NOT NULL,
|
||||
actor_user_agent TEXT NOT NULL,
|
||||
occurred_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_esig_events_signature
|
||||
ON electronic_signature_events (electronic_signature_id, occurred_at ASC);
|
||||
|
||||
-- Email attachments table (generic, not esign-specific)
|
||||
CREATE TABLE email_attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
email_id TEXT NOT NULL REFERENCES emails(id) ON DELETE CASCADE,
|
||||
file_id TEXT NOT NULL REFERENCES files(id),
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_email_attachments_email_id ON email_attachments (email_id);
|
||||
25
pkg/crypto/hash/hash.go
Normal file
25
pkg/crypto/hash/hash.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package hash
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func SHA256Hex(data []byte) string {
|
||||
h := sha256.Sum256(data)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
167
pkg/esign/certgen.go
Normal file
167
pkg/esign/certgen.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package esign
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/digitorus/timestamp"
|
||||
"go.gearno.de/x/ref"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/html2pdf"
|
||||
)
|
||||
|
||||
type (
|
||||
CertificateGenerator struct {
|
||||
HTML2PDFConverter *html2pdf.Converter
|
||||
}
|
||||
|
||||
certificateData struct {
|
||||
SignatureID string
|
||||
OrganizationID string
|
||||
SignerFullName string
|
||||
SignerEmail string
|
||||
SignerIPAddress string
|
||||
SignerUserAgent string
|
||||
DocumentType string
|
||||
DocumentTypeName string
|
||||
FileID string
|
||||
FileHash string
|
||||
Seal string
|
||||
SealVersion int
|
||||
ConsentText string
|
||||
SignedAt string
|
||||
TSAAuthority string
|
||||
TSATime string
|
||||
TSASerial string
|
||||
Events []certificateEvent
|
||||
}
|
||||
|
||||
certificateEvent struct {
|
||||
EventType string
|
||||
Source string
|
||||
Actor string
|
||||
IPAddress string
|
||||
OccurredAt string
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed certificate.html.tmpl
|
||||
certificateTemplateHTML string
|
||||
|
||||
certificateTemplate = template.Must(template.New("certificate").Parse(certificateTemplateHTML))
|
||||
)
|
||||
|
||||
func (g *CertificateGenerator) Generate(
|
||||
ctx context.Context,
|
||||
signature *coredata.ElectronicSignature,
|
||||
events coredata.ElectronicSignatureEvents,
|
||||
) (io.Reader, error) {
|
||||
data := certificateData{
|
||||
SignatureID: signature.ID.String(),
|
||||
OrganizationID: signature.OrganizationID.String(),
|
||||
SignerFullName: ref.UnrefOrZero(signature.SignerFullName),
|
||||
SignerEmail: signature.SignerEmail,
|
||||
SignerIPAddress: ref.UnrefOrZero(signature.SignerIPAddress),
|
||||
SignerUserAgent: ref.UnrefOrZero(signature.SignerUserAgent),
|
||||
DocumentType: signature.DocumentType.String(),
|
||||
DocumentTypeName: signature.DocumentType.DisplayName(),
|
||||
FileID: signature.FileID.String(),
|
||||
FileHash: ref.UnrefOrZero(signature.FileHash),
|
||||
Seal: ref.UnrefOrZero(signature.Seal),
|
||||
SealVersion: signature.SealVersion,
|
||||
ConsentText: signature.ConsentText,
|
||||
}
|
||||
|
||||
if signature.SignedAt == nil {
|
||||
return nil, fmt.Errorf("cannot generate certificate: signature %s has no signed_at timestamp", signature.ID)
|
||||
}
|
||||
data.SignedAt = signature.SignedAt.UTC().Format(time.RFC3339)
|
||||
|
||||
if len(signature.TSAToken) == 0 {
|
||||
return nil, fmt.Errorf("cannot generate certificate: signature %s has no TSA token", signature.ID)
|
||||
}
|
||||
|
||||
tsResp, err := timestamp.ParseResponse(signature.TSAToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse TSA token for signature %s: %w", signature.ID, err)
|
||||
}
|
||||
|
||||
data.TSATime = tsResp.Time.UTC().Format(time.RFC3339)
|
||||
data.TSASerial = tsResp.SerialNumber.String()
|
||||
data.TSAAuthority = tsaAuthorityName(tsResp)
|
||||
|
||||
for _, evt := range events {
|
||||
data.Events = append(
|
||||
data.Events,
|
||||
certificateEvent{
|
||||
EventType: evt.EventType.String(),
|
||||
Source: evt.EventSource.String(),
|
||||
Actor: evt.ActorEmail,
|
||||
IPAddress: evt.ActorIPAddress,
|
||||
OccurredAt: evt.OccurredAt.UTC().Format(time.RFC3339),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var htmlBuf bytes.Buffer
|
||||
if err := certificateTemplate.Execute(&htmlBuf, data); err != nil {
|
||||
return nil, fmt.Errorf("cannot render certificate template: %w", err)
|
||||
}
|
||||
|
||||
pdfReader, err := g.HTML2PDFConverter.GeneratePDF(
|
||||
ctx,
|
||||
htmlBuf.Bytes(),
|
||||
html2pdf.RenderConfig{
|
||||
PageFormat: html2pdf.PageFormatA4,
|
||||
Orientation: html2pdf.OrientationPortrait,
|
||||
MarginTop: html2pdf.NewMarginMillimeters(20),
|
||||
MarginBottom: html2pdf.NewMarginMillimeters(20),
|
||||
MarginLeft: html2pdf.NewMarginMillimeters(20),
|
||||
MarginRight: html2pdf.NewMarginMillimeters(20),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate certificate PDF: %w", err)
|
||||
}
|
||||
|
||||
return pdfReader, nil
|
||||
}
|
||||
|
||||
func tsaAuthorityName(ts *timestamp.Timestamp) string {
|
||||
if len(ts.Certificates) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
cert := ts.Certificates[0]
|
||||
|
||||
if len(cert.Subject.Organization) > 0 {
|
||||
org := strings.Join(cert.Subject.Organization, ", ")
|
||||
if cert.Subject.CommonName != "" {
|
||||
return org + " (" + cert.Subject.CommonName + ")"
|
||||
}
|
||||
return org
|
||||
}
|
||||
|
||||
return cert.Subject.CommonName
|
||||
}
|
||||
520
pkg/esign/certificate.html.tmpl
Normal file
520
pkg/esign/certificate.html.tmpl
Normal file
@@ -0,0 +1,520 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 9.5px;
|
||||
color: #000;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* -- Top bar -- */
|
||||
.top-bar {
|
||||
border-top: 4px solid #000;
|
||||
padding-top: 10px;
|
||||
margin-bottom: 6px;
|
||||
display: table;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.top-bar-left {
|
||||
display: table-cell;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.top-bar-right {
|
||||
display: table-cell;
|
||||
vertical-align: top;
|
||||
text-align: right;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.cert-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
color: #000;
|
||||
border-bottom: 2px solid #000;
|
||||
padding-bottom: 4px;
|
||||
display: inline-block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* -- Meta line (ID + Status) -- */
|
||||
.meta-line {
|
||||
display: table;
|
||||
width: 100%;
|
||||
margin-bottom: 2px;
|
||||
font-size: 9.5px;
|
||||
}
|
||||
|
||||
.meta-left {
|
||||
display: table-cell;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.meta-right {
|
||||
display: table-cell;
|
||||
vertical-align: top;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* -- Info rows -- */
|
||||
.info-row {
|
||||
font-size: 9.5px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
/* -- Info grid (multi-column) -- */
|
||||
.info-grid {
|
||||
display: table;
|
||||
width: 100%;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.info-col {
|
||||
display: table-cell;
|
||||
vertical-align: top;
|
||||
font-size: 9.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-col-left {
|
||||
width: 38%;
|
||||
}
|
||||
|
||||
.info-col-mid {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.info-col-right {
|
||||
width: 32%;
|
||||
}
|
||||
|
||||
/* -- Section header bar -- */
|
||||
.section-bar {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.section-bar td {
|
||||
padding: 5px 6px;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
background: #e8e8e8;
|
||||
border-top: 2px solid #000;
|
||||
}
|
||||
|
||||
/* -- Signer content -- */
|
||||
.signer-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.signer-table td {
|
||||
padding: 6px 6px;
|
||||
vertical-align: top;
|
||||
font-size: 9.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.signer-name {
|
||||
font-weight: 700;
|
||||
font-size: 9.5px;
|
||||
}
|
||||
|
||||
/* -- Events table -- */
|
||||
.events-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.events-table thead td {
|
||||
padding: 5px 6px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
background: #e8e8e8;
|
||||
border-top: 2px solid #000;
|
||||
}
|
||||
|
||||
.events-table tbody td {
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* -- Detail table (key-value) -- */
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.detail-table td {
|
||||
padding: 3px 6px;
|
||||
vertical-align: top;
|
||||
font-size: 9px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.detail-table .lbl {
|
||||
width: 170px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* -- Mono -- */
|
||||
.mono {
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 8.5px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* -- Consent -- */
|
||||
.consent-box {
|
||||
padding: 6px;
|
||||
font-size: 9px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
/* -- Verification -- */
|
||||
.verification-box {
|
||||
padding: 6px;
|
||||
font-size: 9px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.verification-box ol {
|
||||
margin: 4px 0 0 18px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* -- ERSD -- */
|
||||
.ersd-section {
|
||||
page-break-before: always;
|
||||
}
|
||||
|
||||
.ersd-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: #000;
|
||||
border-bottom: 2px solid #000;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ersd-body {
|
||||
font-size: 9.5px;
|
||||
color: #000;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ersd-body p {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.ersd-body h3 {
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.ersd-body ul {
|
||||
margin: 2px 0 6px 18px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* -- Footer -- */
|
||||
.footer {
|
||||
margin-top: 16px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid #999;
|
||||
font-size: 8px;
|
||||
color: #777;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Top bar with title and brand -->
|
||||
<div class="top-bar">
|
||||
<div class="top-bar-left">
|
||||
<div class="cert-title">Certificate Of Completion</div>
|
||||
</div>
|
||||
<div class="top-bar-right">
|
||||
<div class="brand">probo</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Signature ID + Status -->
|
||||
<div class="meta-line">
|
||||
<div class="meta-left">
|
||||
Signature Id: {{.SignatureID}}
|
||||
</div>
|
||||
<div class="meta-right">
|
||||
<strong>Status: Completed</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subject -->
|
||||
<div class="info-row">
|
||||
<strong>Subject:</strong> {{.DocumentTypeName}}
|
||||
</div>
|
||||
|
||||
<!-- Document / Source info grid -->
|
||||
<div class="info-grid" style="margin-top: 4px;">
|
||||
<div class="info-col info-col-left">
|
||||
<div>Document Type: {{.DocumentType}}</div>
|
||||
<div>File Hash (SHA-256):</div>
|
||||
<div class="mono">{{.FileHash}}</div>
|
||||
</div>
|
||||
<div class="info-col info-col-mid">
|
||||
|
||||
</div>
|
||||
<div class="info-col info-col-right">
|
||||
<div>Organization Id:</div>
|
||||
<div class="mono">{{.OrganizationID}}</div>
|
||||
<div style="margin-top: 2px;">File Id:</div>
|
||||
<div class="mono">{{.FileID}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Signer Events -->
|
||||
<table class="section-bar">
|
||||
<tr>
|
||||
<td style="width: 40%;">Signer Events</td>
|
||||
<td style="width: 60%;">Timestamp</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table class="signer-table">
|
||||
<tr>
|
||||
<td style="width: 40%;">
|
||||
<div class="signer-name">{{.SignerFullName}}</div>
|
||||
<div>{{.SignerEmail}}</div>
|
||||
<div>Security Level: Email, Account Authentication</div>
|
||||
<div>Using IP Address: {{.SignerIPAddress}}</div>
|
||||
<div style="margin-top: 6px; font-size: 8.5px;">
|
||||
<strong>Electronic Record and Signature Disclosure:</strong><br/>
|
||||
Accepted
|
||||
</div>
|
||||
</td>
|
||||
<td style="width: 60%;">
|
||||
<div><strong>Signed:</strong> {{.SignedAt}}</div>
|
||||
<div style="margin-top: 8px; font-size: 8.5px;">
|
||||
User Agent: {{.SignerUserAgent}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Electronic Consent -->
|
||||
<table class="section-bar">
|
||||
<tr>
|
||||
<td>Electronic Consent</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="consent-box">{{.ConsentText}}</div>
|
||||
|
||||
<!-- Signing Summary Events -->
|
||||
<table class="events-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Signing Summary Events</td>
|
||||
<td>Status</td>
|
||||
<td>Timestamps</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Events}}
|
||||
<tr>
|
||||
<td>{{.EventType}}</td>
|
||||
<td>{{.Source}}</td>
|
||||
<td>{{.OccurredAt}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Integrity Verification -->
|
||||
<table class="section-bar">
|
||||
<tr>
|
||||
<td>Integrity Verification</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table class="detail-table">
|
||||
<tr>
|
||||
<td class="lbl">Seal (SHA-256, v{{.SealVersion}})</td>
|
||||
<td><span class="mono">{{.Seal}}</span></td>
|
||||
</tr>
|
||||
{{if .TSAAuthority}}
|
||||
<tr>
|
||||
<td class="lbl">TSA Authority</td>
|
||||
<td>{{.TSAAuthority}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{if .TSATime}}
|
||||
<tr>
|
||||
<td class="lbl">TSA Timestamp</td>
|
||||
<td>{{.TSATime}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{if .TSASerial}}
|
||||
<tr>
|
||||
<td class="lbl">TSA Serial Number</td>
|
||||
<td>{{.TSASerial}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
|
||||
<!-- Seal Verification -->
|
||||
<table class="section-bar">
|
||||
<tr>
|
||||
<td>Seal Verification (v{{.SealVersion}})</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="verification-box">
|
||||
To independently verify the integrity seal, compute the SHA-256 hash of
|
||||
the following fields joined by newline characters (<span class="mono">\n</span>),
|
||||
in this exact order:
|
||||
<ol>
|
||||
<li>Signature ID</li>
|
||||
<li>Organization ID</li>
|
||||
<li>Document Type (the raw value, e.g. <span class="mono">NDA</span>)</li>
|
||||
<li>File ID</li>
|
||||
<li>File Hash (SHA-256, lowercase hex)</li>
|
||||
<li>Signer Full Name</li>
|
||||
<li>Signer Email (lowercased)</li>
|
||||
<li>Signer IP Address</li>
|
||||
<li>Signer User Agent</li>
|
||||
<li>Consent Text (the full text shown above)</li>
|
||||
<li>Signed At (UTC, RFC 3339 with nanoseconds, truncated to microsecond precision)</li>
|
||||
</ol>
|
||||
<div style="margin-top: 4px;">
|
||||
The resulting lowercase hex-encoded SHA-256 digest must match the seal
|
||||
shown above.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Electronic Record and Signature Disclosure -->
|
||||
<div class="ersd-section">
|
||||
<div class="ersd-title">Electronic Record and Signature Disclosure</div>
|
||||
<div class="ersd-body">
|
||||
<p>
|
||||
From time to time, the organization identified in this certificate (we, us, or
|
||||
the Company) may be required by law to provide to you certain written notices or
|
||||
disclosures. Described below are the terms and conditions for providing to you
|
||||
such notices and disclosures electronically through the Probo platform. Please
|
||||
read the information below carefully and thoroughly.
|
||||
</p>
|
||||
|
||||
<h3>Withdrawing your consent</h3>
|
||||
<p>
|
||||
If you decide to receive notices and disclosures from us electronically, you may
|
||||
at any time change your mind and tell us that you wish to withdraw your consent to
|
||||
receive notices and disclosures electronically. How you must inform us of your
|
||||
decision is described below.
|
||||
</p>
|
||||
|
||||
<h3>All notices and disclosures will be sent to you electronically</h3>
|
||||
<p>
|
||||
Unless you tell us otherwise in accordance with the procedures described herein, we
|
||||
will provide electronically to you through the Probo platform all required notices,
|
||||
disclosures, authorizations, acknowledgements, and other documents that are required
|
||||
to be provided or made available to you during the course of our relationship with
|
||||
you. To reduce the chance of you inadvertently not receiving any notice or
|
||||
disclosure, we prefer to provide all of the required notices and disclosures to you
|
||||
by the same method and to the same address that you have given us.
|
||||
</p>
|
||||
|
||||
<h3>How to contact us</h3>
|
||||
<p>
|
||||
You may contact us to let us know of your changes as to how we may contact you
|
||||
electronically, and to withdraw your prior consent to receive notices and
|
||||
disclosures electronically. To reach us, please send an email to
|
||||
<strong>legal@getprobo.com</strong>.
|
||||
</p>
|
||||
|
||||
<h3>To advise us of your new email address</h3>
|
||||
<p>
|
||||
To let us know of a change in your email address where we should send notices and
|
||||
disclosures electronically to you, you must send an email message to us at
|
||||
legal@getprobo.com and in the body of such request you must state your previous
|
||||
email address and your new email address. We do not require any other information
|
||||
from you to change your email address.
|
||||
</p>
|
||||
|
||||
<h3>To withdraw your consent</h3>
|
||||
<p>
|
||||
To inform us that you no longer wish to receive future notices and disclosures in
|
||||
electronic format you may:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
decline to sign a document from within your signing session, and on the
|
||||
subsequent page, indicate you wish to withdraw your consent; or
|
||||
</li>
|
||||
<li>
|
||||
send us an email to legal@getprobo.com and in the body of such request you
|
||||
must state your email, full name, mailing address, and telephone number. We do
|
||||
not need any other information from you to withdraw consent. The consequences of
|
||||
your withdrawing consent for online documents will be that transactions may take
|
||||
a longer time to process.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>Acknowledging your access and consent to receive and sign documents electronically</h3>
|
||||
<p>
|
||||
By signing the document electronically through the Probo platform, you confirm
|
||||
that:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
You can access and read this Electronic Record and Signature Disclosure; and
|
||||
</li>
|
||||
<li>
|
||||
You can print on paper this Electronic Record and Signature Disclosure, or save
|
||||
or send this Electronic Record and Signature Disclosure to a location where you
|
||||
can print it, for future reference and access; and
|
||||
</li>
|
||||
<li>
|
||||
Until or unless you notify us as described above, you consent to receive
|
||||
exclusively through electronic means all notices, disclosures, authorizations,
|
||||
acknowledgements, and other documents that are required to be provided or made
|
||||
available to you during the course of your relationship with us.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
This certificate was generated automatically by Probo. The integrity
|
||||
seal and TSA timestamp provide tamper-evident proof of the signing
|
||||
event. All information required to independently recompute and verify
|
||||
the seal is included in this document.
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
375
pkg/esign/completion_certificate_worker.go
Normal file
375
pkg/esign/completion_certificate_worker.go
Normal file
@@ -0,0 +1,375 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package esign
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/crypto/uuid"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.gearno.de/x/ref"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/filemanager"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/mail"
|
||||
|
||||
emails "go.probo.inc/probo/packages/emails"
|
||||
)
|
||||
|
||||
// EmailPresenterConfigFunc resolves the emails.PresenterConfig for the
|
||||
// organization that owns the given trust center.
|
||||
type EmailPresenterConfigFunc func(ctx context.Context, organizationID gid.GID) (emails.PresenterConfig, error)
|
||||
|
||||
type (
|
||||
CompletionCertificateWorker struct {
|
||||
pg *pg.Client
|
||||
fileManager *filemanager.Service
|
||||
certificateGen *CertificateGenerator
|
||||
presenterConfigFunc EmailPresenterConfigFunc
|
||||
bucket string
|
||||
logger *log.Logger
|
||||
interval time.Duration
|
||||
staleAfter time.Duration
|
||||
maxConcurrency int
|
||||
}
|
||||
|
||||
CompletionCertificateWorkerOption func(*CompletionCertificateWorker)
|
||||
)
|
||||
|
||||
const (
|
||||
certificateFilename = "certificate-of-completion.pdf"
|
||||
)
|
||||
|
||||
func WithCompletionCertificateWorkerInterval(d time.Duration) CompletionCertificateWorkerOption {
|
||||
return func(w *CompletionCertificateWorker) { w.interval = d }
|
||||
}
|
||||
|
||||
func WithCompletionCertificateWorkerStaleAfter(d time.Duration) CompletionCertificateWorkerOption {
|
||||
return func(w *CompletionCertificateWorker) { w.staleAfter = d }
|
||||
}
|
||||
|
||||
func WithCompletionCertificateWorkerMaxConcurrency(n int) CompletionCertificateWorkerOption {
|
||||
return func(w *CompletionCertificateWorker) {
|
||||
if n > 0 {
|
||||
w.maxConcurrency = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewCompletionCertificateWorker(
|
||||
pgClient *pg.Client,
|
||||
fileManager *filemanager.Service,
|
||||
certificateGen *CertificateGenerator,
|
||||
presenterConfigFunc EmailPresenterConfigFunc,
|
||||
bucket string,
|
||||
logger *log.Logger,
|
||||
opts ...CompletionCertificateWorkerOption,
|
||||
) *CompletionCertificateWorker {
|
||||
w := &CompletionCertificateWorker{
|
||||
pg: pgClient,
|
||||
fileManager: fileManager,
|
||||
certificateGen: certificateGen,
|
||||
presenterConfigFunc: presenterConfigFunc,
|
||||
bucket: bucket,
|
||||
logger: logger,
|
||||
interval: 10 * time.Second,
|
||||
staleAfter: 10 * time.Minute,
|
||||
maxConcurrency: 5,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(w)
|
||||
}
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *CompletionCertificateWorker) Run(ctx context.Context) error {
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
sem = make(chan struct{}, w.maxConcurrency)
|
||||
)
|
||||
defer wg.Wait()
|
||||
|
||||
LOOP:
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(w.interval):
|
||||
// From there we should not accept cancelations anymore.
|
||||
nonCancelableCtx := context.WithoutCancel(ctx)
|
||||
|
||||
w.recoverStaleCertificateRows(nonCancelableCtx)
|
||||
for {
|
||||
if err := w.processNext(nonCancelableCtx, sem, &wg); err != nil {
|
||||
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
w.logger.ErrorCtx(nonCancelableCtx, "cannot process certificate", log.Error(err))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
goto LOOP
|
||||
}
|
||||
}
|
||||
|
||||
func (w *CompletionCertificateWorker) processNext(ctx context.Context, sem chan struct{}, wg *sync.WaitGroup) error {
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
var (
|
||||
signature coredata.ElectronicSignature
|
||||
now = time.Now()
|
||||
)
|
||||
|
||||
if err := w.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := signature.LoadNextCompletedWithoutCertificateForUpdate(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
scope := coredata.NewScopeFromObjectID(signature.ID)
|
||||
signature.CertificateProcessingStartedAt = &now
|
||||
signature.UpdatedAt = now
|
||||
|
||||
if err := signature.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update signature: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
<-sem
|
||||
return err
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(signature coredata.ElectronicSignature) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
scope := coredata.NewScopeFromObjectID(signature.ID)
|
||||
|
||||
if err := w.generateAndCommit(ctx, &signature); err != nil {
|
||||
if err := w.handleCertFailure(ctx, &signature, scope, err); err != nil {
|
||||
w.logger.ErrorCtx(ctx, "cannot handle certificate failure", log.Error(err))
|
||||
}
|
||||
}
|
||||
}(signature)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *CompletionCertificateWorker) generateAndCommit(
|
||||
ctx context.Context,
|
||||
signature *coredata.ElectronicSignature,
|
||||
) error {
|
||||
var (
|
||||
scope = coredata.NewScopeFromObjectID(signature.ID)
|
||||
)
|
||||
|
||||
email, attachments, err := w.generateCertificate(ctx, signature, scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := w.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
signature.CertificateFileID = &attachments[1].FileID
|
||||
signature.UpdatedAt = time.Now()
|
||||
if err := signature.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update signature: %w", err)
|
||||
}
|
||||
|
||||
event := signature.NewEvent(
|
||||
coredata.ElectronicSignatureEventTypeCertificateGenerated,
|
||||
coredata.ElectronicSignatureEventSourceServer,
|
||||
)
|
||||
if err := event.Insert(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert certificate event: %w", err)
|
||||
}
|
||||
|
||||
if err := email.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert certificate email: %w", err)
|
||||
}
|
||||
|
||||
for _, attachment := range attachments {
|
||||
if err := attachment.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert email attachment: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *CompletionCertificateWorker) generateCertificate(
|
||||
ctx context.Context,
|
||||
signature *coredata.ElectronicSignature,
|
||||
scope coredata.Scoper,
|
||||
) (*coredata.Email, coredata.EmailAttachments, error) {
|
||||
var (
|
||||
events = coredata.ElectronicSignatureEvents{}
|
||||
signedFile = coredata.File{}
|
||||
)
|
||||
|
||||
if err := w.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := events.LoadBySignatureID(ctx, conn, scope, signature.ID); err != nil {
|
||||
return fmt.Errorf("cannot load events: %w", err)
|
||||
}
|
||||
|
||||
if err := signedFile.LoadByID(ctx, conn, scope, signature.FileID); err != nil {
|
||||
return fmt.Errorf("cannot load signed file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
certificatePDFReader, err := w.certificateGen.Generate(ctx, signature, events)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot generate certificate: %w", err)
|
||||
}
|
||||
|
||||
certificateOfCompletionFile := coredata.File{
|
||||
ID: gid.New(scope.GetTenantID(), coredata.FileEntityType),
|
||||
OrganizationID: signature.OrganizationID,
|
||||
BucketName: w.bucket,
|
||||
MimeType: "application/pdf",
|
||||
FileName: certificateFilename,
|
||||
FileKey: uuid.MustNewV4().String(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
certificateOfCompletionFileSize, err := w.fileManager.PutFile(
|
||||
ctx,
|
||||
&certificateOfCompletionFile,
|
||||
certificatePDFReader,
|
||||
map[string]string{
|
||||
"type": "certificate-of-completion",
|
||||
"signature-id": signature.ID.String(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot upload cert to S3: %w", err)
|
||||
}
|
||||
|
||||
certificateOfCompletionFile.FileSize = certificateOfCompletionFileSize
|
||||
|
||||
if err := w.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := certificateOfCompletionFile.Insert(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert certificate of completion file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
presenterCfg, err := w.presenterConfigFunc(ctx, signature.OrganizationID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot resolve presenter config: %w", err)
|
||||
}
|
||||
emailPresenter := emails.NewPresenterFromConfig(w.fileManager, presenterCfg, ref.UnrefOrZero(signature.SignerFullName))
|
||||
|
||||
docTypeName := signature.DocumentType.DisplayName()
|
||||
subject, textBody, htmlBody, err := emailPresenter.RenderElectronicSignatureCertificate(ctx, ref.UnrefOrZero(signature.SignerFullName), docTypeName)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot render email: %w", err)
|
||||
}
|
||||
|
||||
email := coredata.NewEmail(
|
||||
ref.UnrefOrZero(signature.SignerFullName),
|
||||
mail.Addr(signature.SignerEmail),
|
||||
subject,
|
||||
textBody,
|
||||
htmlBody,
|
||||
)
|
||||
|
||||
attachments := coredata.EmailAttachments{
|
||||
coredata.NewEmailAttachment(
|
||||
email.ID,
|
||||
signedFile.ID,
|
||||
signedFile.FileName,
|
||||
signedFile.MimeType,
|
||||
),
|
||||
coredata.NewEmailAttachment(
|
||||
email.ID,
|
||||
certificateOfCompletionFile.ID,
|
||||
certificateFilename,
|
||||
"application/pdf",
|
||||
),
|
||||
}
|
||||
|
||||
return email, attachments, nil
|
||||
}
|
||||
|
||||
func (w *CompletionCertificateWorker) handleCertFailure(
|
||||
ctx context.Context,
|
||||
signature *coredata.ElectronicSignature,
|
||||
scope coredata.Scoper,
|
||||
processingError error,
|
||||
) error {
|
||||
w.logger.ErrorCtx(ctx, "certificate worker failure",
|
||||
log.Error(processingError),
|
||||
log.String("signature_id", signature.ID.String()),
|
||||
)
|
||||
|
||||
return w.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
signature.CertificateProcessingStartedAt = nil
|
||||
signature.UpdatedAt = time.Now()
|
||||
|
||||
if err := signature.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update signature: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (w *CompletionCertificateWorker) recoverStaleCertificateRows(ctx context.Context) {
|
||||
if err := w.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
return coredata.ResetStaleCertificateProcessing(ctx, conn, w.staleAfter)
|
||||
},
|
||||
); err != nil {
|
||||
w.logger.ErrorCtx(ctx, "cannot recover stale certificates", log.Error(err))
|
||||
}
|
||||
}
|
||||
344
pkg/esign/sealing_worker.go
Normal file
344
pkg/esign/sealing_worker.go
Normal file
@@ -0,0 +1,344 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package esign
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/crypto/hash"
|
||||
"go.probo.inc/probo/pkg/filemanager"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLoadFile = errors.New("esign: cannot load file")
|
||||
ErrDownloadPDF = errors.New("esign: cannot download PDF")
|
||||
ErrComputeSeal = errors.New("esign: cannot compute seal")
|
||||
ErrTSATimestamp = errors.New("esign: cannot get TSA timestamp")
|
||||
)
|
||||
|
||||
const (
|
||||
currentSealVersion = 1
|
||||
)
|
||||
|
||||
type (
|
||||
SealingWorker struct {
|
||||
pg *pg.Client
|
||||
fileManager *filemanager.Service
|
||||
tsaClient *TSAClient
|
||||
logger *log.Logger
|
||||
interval time.Duration
|
||||
tsaTimeout time.Duration
|
||||
staleAfter time.Duration
|
||||
maxConcurrency int
|
||||
}
|
||||
|
||||
SealingWorkerOption func(*SealingWorker)
|
||||
)
|
||||
|
||||
func WithSealingWorkerInterval(d time.Duration) SealingWorkerOption {
|
||||
return func(w *SealingWorker) { w.interval = d }
|
||||
}
|
||||
|
||||
func WithSealingWorkerTSATimeout(d time.Duration) SealingWorkerOption {
|
||||
return func(w *SealingWorker) { w.tsaTimeout = d }
|
||||
}
|
||||
|
||||
func WithSealingWorkerStaleAfter(d time.Duration) SealingWorkerOption {
|
||||
return func(w *SealingWorker) { w.staleAfter = d }
|
||||
}
|
||||
|
||||
func WithSealingWorkerMaxConcurrency(n int) SealingWorkerOption {
|
||||
return func(w *SealingWorker) {
|
||||
if n > 0 {
|
||||
w.maxConcurrency = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewSealingWorker(
|
||||
pgClient *pg.Client,
|
||||
fileManager *filemanager.Service,
|
||||
tsaClient *TSAClient,
|
||||
logger *log.Logger,
|
||||
opts ...SealingWorkerOption,
|
||||
) *SealingWorker {
|
||||
w := &SealingWorker{
|
||||
pg: pgClient,
|
||||
fileManager: fileManager,
|
||||
tsaClient: tsaClient,
|
||||
logger: logger,
|
||||
interval: 10 * time.Second,
|
||||
tsaTimeout: 10 * time.Second,
|
||||
staleAfter: 5 * time.Minute,
|
||||
maxConcurrency: 5,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(w)
|
||||
}
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *SealingWorker) Run(ctx context.Context) error {
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
sem = make(chan struct{}, w.maxConcurrency)
|
||||
)
|
||||
|
||||
defer wg.Wait()
|
||||
|
||||
LOOP:
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(w.interval):
|
||||
// From there we should not accept cancelations anymore.
|
||||
nonCancelableCtx := context.WithoutCancel(ctx)
|
||||
|
||||
w.recoverStaleRows(nonCancelableCtx)
|
||||
for {
|
||||
if err := w.processNext(nonCancelableCtx, sem, &wg); err != nil {
|
||||
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
w.logger.ErrorCtx(nonCancelableCtx, "cannot claim signature", log.Error(err))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
goto LOOP
|
||||
}
|
||||
}
|
||||
|
||||
func (w *SealingWorker) processNext(ctx context.Context, sem chan struct{}, wg *sync.WaitGroup) error {
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
var (
|
||||
signature = coredata.ElectronicSignature{}
|
||||
now = time.Now()
|
||||
)
|
||||
|
||||
if err := w.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := signature.LoadNextAcceptedForUpdateSkipLocked(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
signature.Status = coredata.ElectronicSignatureStatusProcessing
|
||||
signature.ProcessingStartedAt = &now
|
||||
signature.AttemptCount++
|
||||
signature.LastAttemptedAt = &now
|
||||
signature.UpdatedAt = now
|
||||
if err := signature.Update(ctx, tx, coredata.NewNoScope()); err != nil {
|
||||
return fmt.Errorf("cannot update signature: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
<-sem
|
||||
return err
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(signature coredata.ElectronicSignature) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
if err := w.sealAndCommit(ctx, &signature); err != nil {
|
||||
if err := w.failSignature(ctx, &signature, err); err != nil {
|
||||
w.logger.ErrorCtx(ctx, "cannot fail signature", log.Error(err))
|
||||
}
|
||||
}
|
||||
}(signature)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *SealingWorker) sealAndCommit(
|
||||
ctx context.Context,
|
||||
signature *coredata.ElectronicSignature,
|
||||
) error {
|
||||
var (
|
||||
scope = coredata.NewScopeFromObjectID(signature.ID)
|
||||
file coredata.File
|
||||
events []coredata.ElectronicSignatureEvent
|
||||
)
|
||||
|
||||
if err := w.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := file.LoadByID(ctx, conn, scope, signature.FileID); err != nil {
|
||||
return fmt.Errorf("cannot load file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrLoadFile, err)
|
||||
}
|
||||
|
||||
pdfBytes, err := w.fileManager.GetFileBytes(ctx, &file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrDownloadPDF, err)
|
||||
}
|
||||
|
||||
fileHash := hash.SHA256Hex(pdfBytes)
|
||||
signature.FileHash = &fileHash
|
||||
|
||||
seal, err := signature.ComputeSeal(currentSealVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrComputeSeal, err)
|
||||
}
|
||||
signature.Seal = &seal
|
||||
signature.SealVersion = currentSealVersion
|
||||
events = append(
|
||||
events,
|
||||
signature.NewEvent(
|
||||
coredata.ElectronicSignatureEventTypeSealComputed,
|
||||
coredata.ElectronicSignatureEventSourceServer,
|
||||
),
|
||||
)
|
||||
|
||||
tsaCtx, cancel := context.WithTimeout(ctx, w.tsaTimeout)
|
||||
defer cancel()
|
||||
tsaToken, err := w.tsaClient.Timestamp(tsaCtx, []byte(seal))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrTSATimestamp, err)
|
||||
}
|
||||
signature.TSAToken = tsaToken
|
||||
events = append(
|
||||
events,
|
||||
signature.NewEvent(
|
||||
coredata.ElectronicSignatureEventTypeTimestampRequested,
|
||||
coredata.ElectronicSignatureEventSourceServer,
|
||||
),
|
||||
)
|
||||
|
||||
if err := w.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
var current coredata.ElectronicSignature
|
||||
if err := current.LoadByID(ctx, tx, scope, signature.ID); err != nil {
|
||||
return fmt.Errorf("cannot load signature: %w", err)
|
||||
}
|
||||
|
||||
if current.Status != coredata.ElectronicSignatureStatusProcessing {
|
||||
return fmt.Errorf("esign: unexpected status %s, expected PROCESSING", current.Status)
|
||||
}
|
||||
|
||||
signature.Status = coredata.ElectronicSignatureStatusCompleted
|
||||
signature.UpdatedAt = time.Now()
|
||||
if err := signature.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update signature: %w", err)
|
||||
}
|
||||
events = append(
|
||||
events,
|
||||
signature.NewEvent(
|
||||
coredata.ElectronicSignatureEventTypeSignatureCompleted,
|
||||
coredata.ElectronicSignatureEventSourceServer,
|
||||
),
|
||||
)
|
||||
|
||||
for i := range events {
|
||||
if err := events[i].Insert(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert %s event: %w", events[i].EventType, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot commit signing results: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *SealingWorker) failSignature(
|
||||
ctx context.Context,
|
||||
signature *coredata.ElectronicSignature,
|
||||
processingError error,
|
||||
) error {
|
||||
scope := coredata.NewScopeFromObjectID(signature.ID)
|
||||
|
||||
w.logger.ErrorCtx(ctx, "sealing worker failure",
|
||||
log.Error(processingError),
|
||||
log.String("signature_id", signature.ID.String()),
|
||||
)
|
||||
|
||||
return w.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
errStr := userFacingError(processingError)
|
||||
signature.LastError = &errStr
|
||||
signature.ProcessingStartedAt = nil
|
||||
signature.UpdatedAt = time.Now()
|
||||
if signature.AttemptCount >= signature.MaxAttempts {
|
||||
signature.Status = coredata.ElectronicSignatureStatusFailed
|
||||
} else {
|
||||
signature.Status = coredata.ElectronicSignatureStatusAccepted
|
||||
}
|
||||
if err := signature.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update signature: %w", err)
|
||||
}
|
||||
|
||||
event := signature.NewEvent(coredata.ElectronicSignatureEventTypeProcessingError, coredata.ElectronicSignatureEventSourceServer)
|
||||
if err := event.Insert(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert PROCESSING_ERROR event: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func userFacingError(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, ErrTSATimestamp):
|
||||
return "The timestamp authority is temporarily unavailable."
|
||||
case errors.Is(err, ErrLoadFile):
|
||||
return "Unable to load the document for signing."
|
||||
case errors.Is(err, ErrDownloadPDF):
|
||||
return "Unable to retrieve the document."
|
||||
case errors.Is(err, ErrComputeSeal):
|
||||
return "Unable to generate the cryptographic seal."
|
||||
default:
|
||||
return "An unexpected error occurred while processing your signature."
|
||||
}
|
||||
}
|
||||
|
||||
func (w *SealingWorker) recoverStaleRows(ctx context.Context) {
|
||||
if err := w.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
return coredata.ResetStaleProcessingSignatures(ctx, conn, w.staleAfter)
|
||||
},
|
||||
); err != nil {
|
||||
w.logger.ErrorCtx(ctx, "cannot recover stale signatures", log.Error(err))
|
||||
}
|
||||
}
|
||||
352
pkg/esign/service.go
Normal file
352
pkg/esign/service.go
Normal file
@@ -0,0 +1,352 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package esign
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/httpclient"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/filemanager"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/html2pdf"
|
||||
"go.probo.inc/probo/pkg/mail"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// Service manages the electronic signature lifecycle.
|
||||
type (
|
||||
Service struct {
|
||||
pg *pg.Client
|
||||
fileManager *filemanager.Service
|
||||
tsaClient *TSAClient
|
||||
certificateGen *CertificateGenerator
|
||||
bucket string
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
CreateSignatureRequest struct {
|
||||
OrganizationID gid.GID
|
||||
DocumentType coredata.ElectronicSignatureDocumentType
|
||||
FileID gid.GID
|
||||
SignerEmail mail.Addr
|
||||
ConsentText string // optional; required when DocumentType == OTHER
|
||||
}
|
||||
|
||||
AcceptSignatureRequest struct {
|
||||
SignatureID gid.GID
|
||||
SignerFullName string
|
||||
SignerEmail mail.Addr
|
||||
SignerIPAddr string
|
||||
SignerUA string
|
||||
}
|
||||
|
||||
RecordEventRequest struct {
|
||||
SignatureID gid.GID
|
||||
EventType coredata.ElectronicSignatureEventType
|
||||
EventSource coredata.ElectronicSignatureEventSource
|
||||
ActorEmail mail.Addr
|
||||
ActorIPAddr string
|
||||
ActorUA string
|
||||
}
|
||||
)
|
||||
|
||||
func NewService(
|
||||
pgClient *pg.Client,
|
||||
fileManager *filemanager.Service,
|
||||
html2pdfConverter *html2pdf.Converter,
|
||||
tsaURL string,
|
||||
bucket string,
|
||||
logger *log.Logger,
|
||||
) *Service {
|
||||
httpClient := httpclient.DefaultPooledClient(
|
||||
httpclient.WithLogger(logger),
|
||||
)
|
||||
|
||||
return &Service{
|
||||
pg: pgClient,
|
||||
fileManager: fileManager,
|
||||
tsaClient: &TSAClient{URL: tsaURL, HTTPClient: httpClient},
|
||||
certificateGen: &CertificateGenerator{
|
||||
HTML2PDFConverter: html2pdfConverter,
|
||||
},
|
||||
bucket: bucket,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Run(ctx context.Context, presenterConfigFunc EmailPresenterConfigFunc) error {
|
||||
g := errgroup.Group{}
|
||||
|
||||
sealingWorkerCtx, stopSealingWorker := context.WithCancel(context.Background())
|
||||
sealingWorker := NewSealingWorker(
|
||||
s.pg,
|
||||
s.fileManager,
|
||||
s.tsaClient,
|
||||
s.logger.Named("sealing-worker"),
|
||||
)
|
||||
g.Go(func() error { return sealingWorker.Run(sealingWorkerCtx) })
|
||||
|
||||
certWorkerCtx, stopCertWorker := context.WithCancel(context.Background())
|
||||
certWorker := NewCompletionCertificateWorker(
|
||||
s.pg,
|
||||
s.fileManager,
|
||||
s.certificateGen,
|
||||
presenterConfigFunc,
|
||||
s.bucket,
|
||||
s.logger.Named("completion-certificate-worker"),
|
||||
)
|
||||
g.Go(func() error { return certWorker.Run(certWorkerCtx) })
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
stopSealingWorker()
|
||||
stopCertWorker()
|
||||
|
||||
return g.Wait()
|
||||
}
|
||||
|
||||
// CreateSignatureRequest contains the parameters for creating a PENDING
|
||||
// electronic signature.
|
||||
|
||||
// CreateSignature creates a PENDING electronic signature row. The conn
|
||||
// parameter allows the caller to include this insert inside its own
|
||||
// transaction.
|
||||
func (s *Service) CreateSignature(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
req *CreateSignatureRequest,
|
||||
) (*coredata.ElectronicSignature, error) {
|
||||
consentText := req.ConsentText
|
||||
if consentText == "" {
|
||||
var err error
|
||||
consentText, err = req.DocumentType.ConsentText()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot derive consent text: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Caller provided explicit text; append e-sign process consent
|
||||
// suffix if not already present.
|
||||
if !strings.HasSuffix(consentText, coredata.ESignProcessConsentText) {
|
||||
consentText = consentText + " " + coredata.ESignProcessConsentText
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
scope := coredata.NewScopeFromObjectID(req.OrganizationID)
|
||||
|
||||
sig := &coredata.ElectronicSignature{
|
||||
ID: gid.New(scope.GetTenantID(), coredata.ElectronicSignatureEntityType),
|
||||
OrganizationID: req.OrganizationID,
|
||||
Status: coredata.ElectronicSignatureStatusPending,
|
||||
DocumentType: req.DocumentType,
|
||||
FileID: req.FileID,
|
||||
SignerEmail: req.SignerEmail.String(),
|
||||
ConsentText: consentText,
|
||||
SealVersion: 1,
|
||||
AttemptCount: 0,
|
||||
MaxAttempts: 10,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := sig.Insert(ctx, conn, scope); err != nil {
|
||||
return nil, fmt.Errorf("cannot insert electronic signature: %w", err)
|
||||
}
|
||||
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
func (s *Service) AcceptSignature(ctx context.Context, req *AcceptSignatureRequest) error {
|
||||
var (
|
||||
scope = coredata.NewScopeFromObjectID(req.SignatureID)
|
||||
now = time.Now()
|
||||
)
|
||||
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
var signature coredata.ElectronicSignature
|
||||
if err := signature.LoadByID(ctx, tx, scope, req.SignatureID); err != nil {
|
||||
return fmt.Errorf("cannot load electronic signature: %w", err)
|
||||
}
|
||||
|
||||
if signature.Status != coredata.ElectronicSignatureStatusPending &&
|
||||
signature.Status != coredata.ElectronicSignatureStatusFailed {
|
||||
return fmt.Errorf("cannot accept electronic signature in status %s", signature.Status)
|
||||
}
|
||||
|
||||
// If retrying from FAILED, reset attempt tracking.
|
||||
if signature.Status == coredata.ElectronicSignatureStatusFailed {
|
||||
signature.AttemptCount = 0
|
||||
signature.LastError = nil
|
||||
}
|
||||
|
||||
signature.SignerFullName = &req.SignerFullName
|
||||
signature.SignerIPAddress = &req.SignerIPAddr
|
||||
signature.SignerUserAgent = &req.SignerUA
|
||||
signature.SignedAt = &now
|
||||
signature.Status = coredata.ElectronicSignatureStatusAccepted
|
||||
signature.UpdatedAt = now
|
||||
|
||||
if err := signature.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update signature: %w", err)
|
||||
}
|
||||
|
||||
s.recordEvent(
|
||||
ctx,
|
||||
tx,
|
||||
&RecordEventRequest{
|
||||
SignatureID: signature.ID,
|
||||
EventType: coredata.ElectronicSignatureEventTypeSignatureAccepted,
|
||||
EventSource: coredata.ElectronicSignatureEventSourceServer,
|
||||
ActorEmail: req.SignerEmail,
|
||||
ActorIPAddr: req.SignerIPAddr,
|
||||
ActorUA: req.SignerUA,
|
||||
},
|
||||
)
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) RecordEvent(ctx context.Context, req *RecordEventRequest) error {
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
return s.recordEvent(ctx, tx, req)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) recordEvent(ctx context.Context, tx pg.Conn, req *RecordEventRequest) error {
|
||||
var (
|
||||
now = time.Now()
|
||||
scope = coredata.NewScopeFromObjectID(req.SignatureID)
|
||||
)
|
||||
|
||||
event := coredata.ElectronicSignatureEvent{
|
||||
ID: gid.New(scope.GetTenantID(), coredata.ElectronicSignatureEventEntityType),
|
||||
ElectronicSignatureID: req.SignatureID,
|
||||
EventType: req.EventType,
|
||||
EventSource: req.EventSource,
|
||||
ActorEmail: req.ActorEmail.String(),
|
||||
ActorIPAddress: req.ActorIPAddr,
|
||||
ActorUserAgent: req.ActorUA,
|
||||
OccurredAt: now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
if err := event.Insert(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert signing event: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) LoadSignatureByID(ctx context.Context, id gid.GID) (*coredata.ElectronicSignature, error) {
|
||||
var (
|
||||
scope = coredata.NewScopeFromObjectID(id)
|
||||
signature = coredata.ElectronicSignature{}
|
||||
)
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := signature.LoadByID(ctx, conn, scope, id); err != nil {
|
||||
return fmt.Errorf("cannot load electronic signature: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &signature, nil
|
||||
}
|
||||
|
||||
func (s *Service) LoadSignatureByOrgEmailAndDocType(
|
||||
ctx context.Context,
|
||||
orgID gid.GID,
|
||||
email string,
|
||||
docType coredata.ElectronicSignatureDocumentType,
|
||||
fileID gid.GID,
|
||||
) (*coredata.ElectronicSignature, error) {
|
||||
scope := coredata.NewScopeFromObjectID(orgID)
|
||||
var sig coredata.ElectronicSignature
|
||||
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||
return sig.LoadByOrgEmailAndDocType(ctx, conn, scope, orgID, email, docType, fileID)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sig, nil
|
||||
}
|
||||
|
||||
func (s *Service) GenerateCertificateFileURL(
|
||||
ctx context.Context,
|
||||
certificateFileID gid.GID,
|
||||
expiresIn time.Duration,
|
||||
) (string, error) {
|
||||
scope := coredata.NewScopeFromObjectID(certificateFileID)
|
||||
var file coredata.File
|
||||
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||
return file.LoadByID(ctx, conn, scope, certificateFileID)
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot load certificate file: %w", err)
|
||||
}
|
||||
|
||||
url, err := s.fileManager.GenerateFileUrl(ctx, &file, expiresIn)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot generate certificate file URL: %w", err)
|
||||
}
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func (s *Service) LoadEventsBySignatureID(
|
||||
ctx context.Context,
|
||||
signatureID gid.GID,
|
||||
) (coredata.ElectronicSignatureEvents, error) {
|
||||
var (
|
||||
scope = coredata.NewScopeFromObjectID(signatureID)
|
||||
events = coredata.ElectronicSignatureEvents{}
|
||||
)
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := events.LoadBySignatureID(ctx, conn, scope, signatureID); err != nil {
|
||||
return fmt.Errorf("cannot load events: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
83
pkg/esign/tsa.go
Normal file
83
pkg/esign/tsa.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package esign
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/digitorus/timestamp"
|
||||
"go.gearno.de/kit/httpclient"
|
||||
)
|
||||
|
||||
// TSAClient sends RFC 3161 timestamp requests to a Trusted Timestamp Authority.
|
||||
type TSAClient struct {
|
||||
URL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// Timestamp sends an RFC 3161 TimeStampReq via HTTP POST to the TSA.
|
||||
// The data parameter is the raw bytes to timestamp (typically the seal hex
|
||||
// string as UTF-8 bytes). CreateRequest internally computes SHA-256(data)
|
||||
// to build the MessageImprint. Returns the raw DER-encoded TimeStampResp bytes.
|
||||
func (c *TSAClient) Timestamp(ctx context.Context, data []byte) ([]byte, error) {
|
||||
tsReq, err := timestamp.CreateRequest(
|
||||
bytes.NewReader(data),
|
||||
×tamp.RequestOptions{
|
||||
Hash: crypto.SHA256,
|
||||
Certificates: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("esign: cannot create timestamp request: %w", err)
|
||||
}
|
||||
|
||||
httpClient := c.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = httpclient.DefaultPooledClient()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(tsReq))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("esign: cannot build TSA HTTP request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/timestamp-query")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("esign: TSA request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("esign: TSA returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
respBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("esign: cannot read TSA response: %w", err)
|
||||
}
|
||||
|
||||
// Validate the response: checks PKIStatus and parses the signed TSTInfo.
|
||||
if _, err := timestamp.ParseResponse(respBytes); err != nil {
|
||||
return nil, fmt.Errorf("esign: invalid TSA response: %w", err)
|
||||
}
|
||||
|
||||
return respBytes, nil
|
||||
}
|
||||
@@ -76,6 +76,31 @@ func (s *Service) GetFileBase64(
|
||||
return base64Data, mimeType, nil
|
||||
}
|
||||
|
||||
// GetFileBytes downloads a file from S3 and returns the raw bytes.
|
||||
func (s *Service) GetFileBytes(
|
||||
ctx context.Context,
|
||||
file File,
|
||||
) ([]byte, error) {
|
||||
result, err := s.s3Client.GetObject(
|
||||
ctx,
|
||||
&s3.GetObjectInput{
|
||||
Bucket: aws.String(file.GetBucketName()),
|
||||
Key: aws.String(file.GetObjectKey()),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get file from S3: %w", err)
|
||||
}
|
||||
defer func() { _ = result.Body.Close() }()
|
||||
|
||||
data, err := io.ReadAll(result.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read file data: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetFileSize(content io.Reader) (int64, error) {
|
||||
seeker, ok := content.(io.Seeker)
|
||||
if !ok {
|
||||
|
||||
@@ -24,18 +24,20 @@ import (
|
||||
"net/smtp"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/filemanager"
|
||||
)
|
||||
|
||||
type (
|
||||
Mailer struct {
|
||||
pg *pg.Client
|
||||
l *log.Logger
|
||||
cfg Config
|
||||
interval time.Duration
|
||||
pg *pg.Client
|
||||
fileManager *filemanager.Service
|
||||
l *log.Logger
|
||||
cfg Config
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
Config struct {
|
||||
@@ -50,7 +52,7 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
func NewMailer(pg *pg.Client, l *log.Logger, cfg Config) *Mailer {
|
||||
func NewMailer(pg *pg.Client, fileManager *filemanager.Service, l *log.Logger, cfg Config) *Mailer {
|
||||
// Set a default timeout if not provided
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = 10 * time.Second
|
||||
@@ -61,7 +63,7 @@ func NewMailer(pg *pg.Client, l *log.Logger, cfg Config) *Mailer {
|
||||
cfg.Interval = 60 * time.Second
|
||||
}
|
||||
|
||||
return &Mailer{pg: pg, l: l, cfg: cfg, interval: cfg.Interval}
|
||||
return &Mailer{pg: pg, fileManager: fileManager, l: l, cfg: cfg, interval: cfg.Interval}
|
||||
}
|
||||
|
||||
func (m *Mailer) Run(ctx context.Context) error {
|
||||
@@ -151,6 +153,11 @@ func (m *Mailer) batchSendEmails(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
var attachments coredata.EmailAttachments
|
||||
if err := attachments.LoadByEmailID(ctx, tx, email.ID); err != nil {
|
||||
return fmt.Errorf("cannot load email attachments: %w", err)
|
||||
}
|
||||
|
||||
mail := enmime.Builder().
|
||||
Subject(email.Subject).
|
||||
From(m.cfg.SenderName, m.cfg.SenderEmail).
|
||||
@@ -161,6 +168,22 @@ func (m *Mailer) batchSendEmails(ctx context.Context) error {
|
||||
mail = mail.HTML([]byte(*email.HtmlBody))
|
||||
}
|
||||
|
||||
for _, att := range attachments {
|
||||
var file coredata.File
|
||||
if err := file.LoadByID(ctx, tx, coredata.NewNoScope(), att.FileID); err != nil {
|
||||
return fmt.Errorf("cannot load file record for attachment %s: %w", att.Filename, err)
|
||||
}
|
||||
|
||||
// TODO use reader
|
||||
data, err := m.fileManager.GetFileBytes(ctx, &file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot download attachment %s: %w", att.Filename, err)
|
||||
}
|
||||
|
||||
// TODO [esign] maybe use AddAttachmentWithReader instead?
|
||||
mail = mail.AddAttachment(data, att.ContentType, att.Filename)
|
||||
}
|
||||
|
||||
envelope, err := mail.Build()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot build email: %w", err)
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"go.probo.inc/probo/pkg/certmanager"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/crypto/cipher"
|
||||
"go.probo.inc/probo/pkg/esign"
|
||||
"go.probo.inc/probo/pkg/filemanager"
|
||||
"go.probo.inc/probo/pkg/filevalidation"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
@@ -61,6 +62,7 @@ type (
|
||||
fileManager *filemanager.Service
|
||||
logger *log.Logger
|
||||
slack *slack.Service
|
||||
esign *esign.Service
|
||||
}
|
||||
|
||||
TenantService struct {
|
||||
@@ -73,6 +75,7 @@ type (
|
||||
tokenSecret string
|
||||
agent *agents.Agent
|
||||
fileManager *filemanager.Service
|
||||
esign *esign.Service
|
||||
Frameworks *FrameworkService
|
||||
Measures *MeasureService
|
||||
Tasks *TaskService
|
||||
@@ -128,6 +131,7 @@ func NewService(
|
||||
logger *log.Logger,
|
||||
slackService *slack.Service,
|
||||
iamService *iam.Service,
|
||||
esignService *esign.Service,
|
||||
) (*Service, error) {
|
||||
if bucket == "" {
|
||||
return nil, fmt.Errorf("bucket is required")
|
||||
@@ -148,6 +152,7 @@ func NewService(
|
||||
fileManager: fileManagerService,
|
||||
logger: logger,
|
||||
slack: slackService,
|
||||
esign: esignService,
|
||||
}
|
||||
|
||||
return svc, nil
|
||||
@@ -164,6 +169,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
|
||||
tokenSecret: s.tokenSecret,
|
||||
agent: agents.NewAgent(nil, s.agentConfig),
|
||||
fileManager: s.fileManager,
|
||||
esign: s.esign,
|
||||
}
|
||||
|
||||
tenantService.Frameworks = &FrameworkService{
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/packages/emails"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/esign"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/mail"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
@@ -253,6 +254,23 @@ func (s TrustCenterAccessService) Create(
|
||||
return fmt.Errorf("cannot insert trust center access: %w", err)
|
||||
}
|
||||
|
||||
if trustCenter.NonDisclosureAgreementFileID != nil && s.svc.esign != nil {
|
||||
_, err := s.svc.esign.CreateSignature(
|
||||
ctx,
|
||||
tx,
|
||||
&esign.CreateSignatureRequest{
|
||||
OrganizationID: access.OrganizationID,
|
||||
DocumentType: coredata.ElectronicSignatureDocumentTypeNDA,
|
||||
FileID: *trustCenter.NonDisclosureAgreementFileID,
|
||||
SignerEmail: access.Email,
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create pending signature: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
@@ -51,6 +51,7 @@ import (
|
||||
"go.probo.inc/probo/pkg/crypto/cipher"
|
||||
"go.probo.inc/probo/pkg/crypto/keys"
|
||||
"go.probo.inc/probo/pkg/crypto/passwdhash"
|
||||
"go.probo.inc/probo/pkg/esign"
|
||||
"go.probo.inc/probo/pkg/filemanager"
|
||||
"go.probo.inc/probo/pkg/html2pdf"
|
||||
"go.probo.inc/probo/pkg/iam"
|
||||
@@ -69,6 +70,10 @@ type (
|
||||
cfg config
|
||||
}
|
||||
|
||||
esignConfig struct {
|
||||
TSAURL string `json:"tsa-url"`
|
||||
}
|
||||
|
||||
config struct {
|
||||
BaseURL *baseurl.BaseURL `json:"base-url"`
|
||||
EncryptionKey cipher.EncryptionKey `json:"encryption-key"`
|
||||
@@ -83,6 +88,7 @@ type (
|
||||
ChromeDPAddr string `json:"chrome-dp-addr"`
|
||||
CustomDomains customDomainsConfig `json:"custom-domains"`
|
||||
SCIMBridge scimBridgeConfig `json:"scim-bridge"`
|
||||
ESign esignConfig `json:"esign"`
|
||||
}
|
||||
|
||||
trustCenterConfig struct {
|
||||
@@ -174,6 +180,9 @@ func New() *Implm {
|
||||
SyncInterval: 60, // 15 minutes
|
||||
PollInterval: 30, // 30 seconds
|
||||
},
|
||||
ESign: esignConfig{
|
||||
TSAURL: "http://timestamp.digicert.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -375,6 +384,15 @@ func (impl *Implm) Run(
|
||||
l.Named("slack"),
|
||||
)
|
||||
|
||||
esignService := esign.NewService(
|
||||
pgClient,
|
||||
fileManagerService,
|
||||
html2pdfConverter,
|
||||
impl.cfg.ESign.TSAURL,
|
||||
impl.cfg.AWS.Bucket,
|
||||
l.Named("esign"),
|
||||
)
|
||||
|
||||
proboService, err := probo.NewService(
|
||||
ctx,
|
||||
impl.cfg.EncryptionKey,
|
||||
@@ -390,6 +408,7 @@ func (impl *Implm) Run(
|
||||
l.Named("probo"),
|
||||
slackService,
|
||||
iamService,
|
||||
esignService,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create probo service: %w", err)
|
||||
@@ -403,6 +422,7 @@ func (impl *Implm) Run(
|
||||
impl.cfg.EncryptionKey,
|
||||
impl.cfg.GetSlackSigningSecret(),
|
||||
iamService,
|
||||
esignService,
|
||||
html2pdfConverter,
|
||||
fileManagerService,
|
||||
l,
|
||||
@@ -416,6 +436,7 @@ func (impl *Implm) Run(
|
||||
Probo: proboService,
|
||||
IAM: iamService,
|
||||
Trust: trustService,
|
||||
ESign: esignService,
|
||||
Slack: slackService,
|
||||
ConnectorRegistry: defaultConnectorRegistry,
|
||||
BaseURL: impl.cfg.BaseURL,
|
||||
@@ -452,6 +473,7 @@ func (impl *Implm) Run(
|
||||
mailerCtx, stopMailer := context.WithCancel(context.Background())
|
||||
mailer := mailer.NewMailer(
|
||||
pgClient,
|
||||
fileManagerService,
|
||||
l,
|
||||
mailer.Config{
|
||||
SenderEmail: impl.cfg.Notifications.Mailer.SenderEmail,
|
||||
@@ -516,6 +538,15 @@ func (impl *Implm) Run(
|
||||
},
|
||||
)
|
||||
|
||||
esignServiceCtx, stopESignService := context.WithCancel(context.Background())
|
||||
wg.Go(
|
||||
func() {
|
||||
if err := esignService.Run(esignServiceCtx, trustService.EmailPresenterConfigByOrganizationID); err != nil {
|
||||
cancel(fmt.Errorf("esign service crashed: %w", err))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
trustCenterServerCtx, stopTrustCenterServer := context.WithCancel(context.Background())
|
||||
defer stopTrustCenterServer()
|
||||
wg.Go(
|
||||
@@ -528,13 +559,14 @@ func (impl *Implm) Run(
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
stopMailer()
|
||||
stopSlackSender()
|
||||
stopWebhookSender()
|
||||
stopExportJobExporter()
|
||||
stopIAMService()
|
||||
stopApiServer()
|
||||
stopTrustCenterServer()
|
||||
stopWebhookSender()
|
||||
stopESignService()
|
||||
stopExportJobExporter()
|
||||
stopIAMService()
|
||||
stopMailer()
|
||||
stopSlackSender()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/baseurl"
|
||||
"go.probo.inc/probo/pkg/connector"
|
||||
"go.probo.inc/probo/pkg/esign"
|
||||
"go.probo.inc/probo/pkg/iam"
|
||||
"go.probo.inc/probo/pkg/probo"
|
||||
"go.probo.inc/probo/pkg/securecookie"
|
||||
@@ -44,6 +45,7 @@ type (
|
||||
Probo *probo.Service
|
||||
IAM *iam.Service
|
||||
Trust *trust.Service
|
||||
ESign *esign.Service
|
||||
Slack *slack.Service
|
||||
Cookie securecookie.Config
|
||||
TokenSecret string
|
||||
@@ -117,6 +119,7 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
cfg.Logger.Named("trust.v1"),
|
||||
cfg.IAM,
|
||||
cfg.Trust,
|
||||
cfg.ESign,
|
||||
cfg.Cookie,
|
||||
cfg.BaseURL,
|
||||
),
|
||||
@@ -124,6 +127,7 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
cfg.Logger.Named("console.v1"),
|
||||
cfg.Probo,
|
||||
cfg.IAM,
|
||||
cfg.ESign,
|
||||
cfg.Cookie,
|
||||
cfg.TokenSecret,
|
||||
cfg.ConnectorRegistry,
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/esign"
|
||||
"go.probo.inc/probo/pkg/iam"
|
||||
"go.probo.inc/probo/pkg/probo"
|
||||
"go.probo.inc/probo/pkg/server/api/authz"
|
||||
@@ -25,12 +26,13 @@ import (
|
||||
"go.probo.inc/probo/pkg/server/gqlutils"
|
||||
)
|
||||
|
||||
func NewGraphQLHandler(iamSvc *iam.Service, proboSvc *probo.Service, customDomainCname string, logger *log.Logger) http.Handler {
|
||||
func NewGraphQLHandler(iamSvc *iam.Service, proboSvc *probo.Service, esignSvc *esign.Service, customDomainCname string, logger *log.Logger) http.Handler {
|
||||
config := schema.Config{
|
||||
Resolvers: &Resolver{
|
||||
authorize: authz.NewAuthorizeFunc(iamSvc, logger),
|
||||
probo: proboSvc,
|
||||
iam: iamSvc,
|
||||
esign: esignSvc,
|
||||
customDomainCname: customDomainCname,
|
||||
logger: logger,
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"go.probo.inc/probo/pkg/baseurl"
|
||||
"go.probo.inc/probo/pkg/connector"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/esign"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/iam"
|
||||
"go.probo.inc/probo/pkg/probo"
|
||||
@@ -47,6 +48,7 @@ type (
|
||||
authorize authz.AuthorizeFunc
|
||||
probo *probo.Service
|
||||
iam *iam.Service
|
||||
esign *esign.Service
|
||||
logger *log.Logger
|
||||
customDomainCname string
|
||||
}
|
||||
@@ -56,6 +58,7 @@ func NewMux(
|
||||
logger *log.Logger,
|
||||
proboSvc *probo.Service,
|
||||
iamSvc *iam.Service,
|
||||
esignSvc *esign.Service,
|
||||
cookieConfig securecookie.Config,
|
||||
tokenSecret string,
|
||||
connectorRegistry *connector.ConnectorRegistry,
|
||||
@@ -66,7 +69,7 @@ func NewMux(
|
||||
|
||||
safeRedirect := &saferedirect.SafeRedirect{AllowedHost: baseURL.Host()}
|
||||
|
||||
graphqlHandler := NewGraphQLHandler(iamSvc, proboSvc, customDomainCname, logger)
|
||||
graphqlHandler := NewGraphQLHandler(iamSvc, proboSvc, esignSvc, customDomainCname, logger)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig))
|
||||
|
||||
@@ -2714,6 +2714,7 @@ type TrustCenterAccess implements Node {
|
||||
name: String!
|
||||
state: TrustCenterAccessState!
|
||||
hasAcceptedNonDisclosureAgreement: Boolean!
|
||||
ndaSignature: ElectronicSignature @goField(forceResolver: true)
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
lastTokenExpiresAt: Datetime
|
||||
@@ -5387,3 +5388,149 @@ type CreateCustomDomainPayload {
|
||||
type DeleteCustomDomainPayload {
|
||||
deletedCustomDomainId: ID!
|
||||
}
|
||||
|
||||
# Electronic Signature
|
||||
|
||||
enum ElectronicSignatureStatus
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatus"
|
||||
) {
|
||||
PENDING
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusPending"
|
||||
)
|
||||
ACCEPTED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusAccepted"
|
||||
)
|
||||
PROCESSING
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusProcessing"
|
||||
)
|
||||
COMPLETED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusCompleted"
|
||||
)
|
||||
FAILED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusFailed"
|
||||
)
|
||||
}
|
||||
|
||||
enum ElectronicSignatureDocumentType
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentType"
|
||||
) {
|
||||
NDA
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeNDA"
|
||||
)
|
||||
DPA
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeDPA"
|
||||
)
|
||||
MSA
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeMSA"
|
||||
)
|
||||
SOW
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeSOW"
|
||||
)
|
||||
SLA
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeSLA"
|
||||
)
|
||||
TOS
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeTOS"
|
||||
)
|
||||
PRIVACY_POLICY
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypePrivacyPolicy"
|
||||
)
|
||||
OTHER
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeOther"
|
||||
)
|
||||
}
|
||||
|
||||
enum ElectronicSignatureEventType
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventType"
|
||||
) {
|
||||
DOCUMENT_VIEWED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeDocumentViewed"
|
||||
)
|
||||
CONSENT_GIVEN
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeConsentGiven"
|
||||
)
|
||||
FULL_NAME_TYPED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeFullNameTyped"
|
||||
)
|
||||
SIGNATURE_ACCEPTED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeSignatureAccepted"
|
||||
)
|
||||
SIGNATURE_COMPLETED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeSignatureCompleted"
|
||||
)
|
||||
SEAL_COMPUTED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeSealComputed"
|
||||
)
|
||||
TIMESTAMP_REQUESTED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeTimestampRequested"
|
||||
)
|
||||
CERTIFICATE_GENERATED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeCertificateGenerated"
|
||||
)
|
||||
PROCESSING_ERROR
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeProcessingError"
|
||||
)
|
||||
}
|
||||
|
||||
enum ElectronicSignatureEventSource
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventSource"
|
||||
) {
|
||||
CLIENT
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventSourceClient"
|
||||
)
|
||||
SERVER
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventSourceServer"
|
||||
)
|
||||
}
|
||||
|
||||
type ElectronicSignature implements Node {
|
||||
id: ID!
|
||||
status: ElectronicSignatureStatus!
|
||||
documentType: ElectronicSignatureDocumentType!
|
||||
consentText: String!
|
||||
lastError: String
|
||||
signedAt: Datetime
|
||||
certificateFileUrl: String @goField(forceResolver: true)
|
||||
events: [ElectronicSignatureEvent!]! @goField(forceResolver: true)
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
}
|
||||
|
||||
type ElectronicSignatureEvent {
|
||||
id: ID!
|
||||
eventType: ElectronicSignatureEventType!
|
||||
eventSource: ElectronicSignatureEventSource!
|
||||
actorEmail: String!
|
||||
actorIpAddress: String!
|
||||
actorUserAgent: String!
|
||||
occurredAt: Datetime!
|
||||
createdAt: Datetime!
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
45
pkg/server/api/console/v1/types/electronic_signature.go
Normal file
45
pkg/server/api/console/v1/types/electronic_signature.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package types
|
||||
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func NewElectronicSignature(es *coredata.ElectronicSignature) *ElectronicSignature {
|
||||
return &ElectronicSignature{
|
||||
ID: es.ID,
|
||||
Status: es.Status,
|
||||
DocumentType: es.DocumentType,
|
||||
ConsentText: es.ConsentText,
|
||||
LastError: es.LastError,
|
||||
SignedAt: es.SignedAt,
|
||||
CreatedAt: es.CreatedAt,
|
||||
UpdatedAt: es.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func NewElectronicSignatureEvent(ev *coredata.ElectronicSignatureEvent) *ElectronicSignatureEvent {
|
||||
return &ElectronicSignatureEvent{
|
||||
ID: ev.ID,
|
||||
EventType: ev.EventType,
|
||||
EventSource: ev.EventSource,
|
||||
ActorEmail: ev.ActorEmail,
|
||||
ActorIPAddress: ev.ActorIPAddress,
|
||||
ActorUserAgent: ev.ActorUserAgent,
|
||||
OccurredAt: ev.OccurredAt,
|
||||
CreatedAt: ev.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -1206,6 +1206,33 @@ type DocumentVersionSignatureOrder struct {
|
||||
Direction page.OrderDirection `json:"direction"`
|
||||
}
|
||||
|
||||
type ElectronicSignature struct {
|
||||
ID gid.GID `json:"id"`
|
||||
Status coredata.ElectronicSignatureStatus `json:"status"`
|
||||
DocumentType coredata.ElectronicSignatureDocumentType `json:"documentType"`
|
||||
ConsentText string `json:"consentText"`
|
||||
LastError *string `json:"lastError,omitempty"`
|
||||
SignedAt *time.Time `json:"signedAt,omitempty"`
|
||||
CertificateFileURL *string `json:"certificateFileUrl,omitempty"`
|
||||
Events []*ElectronicSignatureEvent `json:"events"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (ElectronicSignature) IsNode() {}
|
||||
func (this ElectronicSignature) GetID() gid.GID { return this.ID }
|
||||
|
||||
type ElectronicSignatureEvent struct {
|
||||
ID gid.GID `json:"id"`
|
||||
EventType coredata.ElectronicSignatureEventType `json:"eventType"`
|
||||
EventSource coredata.ElectronicSignatureEventSource `json:"eventSource"`
|
||||
ActorEmail string `json:"actorEmail"`
|
||||
ActorIPAddress string `json:"actorIpAddress"`
|
||||
ActorUserAgent string `json:"actorUserAgent"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Evidence struct {
|
||||
ID gid.GID `json:"id"`
|
||||
Size int `json:"size"`
|
||||
@@ -1847,6 +1874,7 @@ type TrustCenterAccess struct {
|
||||
Name string `json:"name"`
|
||||
State coredata.TrustCenterAccessState `json:"state"`
|
||||
HasAcceptedNonDisclosureAgreement bool `json:"hasAcceptedNonDisclosureAgreement"`
|
||||
NdaSignature *ElectronicSignature `json:"ndaSignature,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
LastTokenExpiresAt *time.Time `json:"lastTokenExpiresAt,omitempty"`
|
||||
|
||||
@@ -1241,6 +1241,40 @@ func (r *documentVersionSignatureConnectionResolver) TotalCount(ctx context.Cont
|
||||
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
|
||||
}
|
||||
|
||||
// CertificateFileURL is the resolver for the certificateFileUrl field.
|
||||
func (r *electronicSignatureResolver) CertificateFileURL(ctx context.Context, obj *types.ElectronicSignature) (*string, error) {
|
||||
sig, err := r.esign.LoadSignatureByID(ctx, obj.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load signature: %w", err)
|
||||
}
|
||||
|
||||
if sig.CertificateFileID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
url, err := r.esign.GenerateCertificateFileURL(ctx, *sig.CertificateFileID, 1*time.Hour)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate certificate file URL: %w", err)
|
||||
}
|
||||
|
||||
return &url, nil
|
||||
}
|
||||
|
||||
// Events is the resolver for the events field.
|
||||
func (r *electronicSignatureResolver) Events(ctx context.Context, obj *types.ElectronicSignature) ([]*types.ElectronicSignatureEvent, error) {
|
||||
events, err := r.esign.LoadEventsBySignatureID(ctx, obj.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load signature events: %w", err)
|
||||
}
|
||||
|
||||
result := make([]*types.ElectronicSignatureEvent, len(events))
|
||||
for i := range events {
|
||||
result[i] = types.NewElectronicSignatureEvent(events[i])
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// File is the resolver for the file field.
|
||||
func (r *evidenceResolver) File(ctx context.Context, obj *types.Evidence) (*types.File, error) {
|
||||
if err := r.authorize(ctx, obj.ID, probo.ActionFileGet); err != nil {
|
||||
@@ -7785,6 +7819,42 @@ func (r *trustCenterResolver) Permission(ctx context.Context, obj *types.TrustCe
|
||||
return r.Resolver.Permission(ctx, obj, action)
|
||||
}
|
||||
|
||||
// NdaSignature is the resolver for the ndaSignature field.
|
||||
func (r *trustCenterAccessResolver) NdaSignature(ctx context.Context, obj *types.TrustCenterAccess) (*types.ElectronicSignature, error) {
|
||||
if err := r.authorize(ctx, obj.ID, probo.ActionTrustCenterAccessGet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prb := r.ProboService(ctx, obj.ID.TenantID())
|
||||
|
||||
access, err := prb.TrustCenterAccesses.Get(ctx, obj.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load trust center access: %w", err)
|
||||
}
|
||||
|
||||
trustCenter, _, err := prb.TrustCenters.Get(ctx, access.TrustCenterID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load trust center: %w", err)
|
||||
}
|
||||
|
||||
if trustCenter.NonDisclosureAgreementFileID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sig, err := r.esign.LoadSignatureByOrgEmailAndDocType(
|
||||
ctx,
|
||||
access.OrganizationID,
|
||||
string(access.Email),
|
||||
coredata.ElectronicSignatureDocumentTypeNDA,
|
||||
*trustCenter.NonDisclosureAgreementFileID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil // No signature row — pre-existing access
|
||||
}
|
||||
|
||||
return types.NewElectronicSignature(sig), nil
|
||||
}
|
||||
|
||||
// PendingRequestCount is the resolver for the pendingRequestCount field.
|
||||
func (r *trustCenterAccessResolver) PendingRequestCount(ctx context.Context, obj *types.TrustCenterAccess) (int, error) {
|
||||
if err := r.authorize(ctx, obj.ID, probo.ActionTrustCenterAccessGet); err != nil {
|
||||
@@ -8784,6 +8854,11 @@ func (r *Resolver) DocumentVersionSignatureConnection() schema.DocumentVersionSi
|
||||
return &documentVersionSignatureConnectionResolver{r}
|
||||
}
|
||||
|
||||
// ElectronicSignature returns schema.ElectronicSignatureResolver implementation.
|
||||
func (r *Resolver) ElectronicSignature() schema.ElectronicSignatureResolver {
|
||||
return &electronicSignatureResolver{r}
|
||||
}
|
||||
|
||||
// Evidence returns schema.EvidenceResolver implementation.
|
||||
func (r *Resolver) Evidence() schema.EvidenceResolver { return &evidenceResolver{r} }
|
||||
|
||||
@@ -9029,6 +9104,7 @@ type documentVersionResolver struct{ *Resolver }
|
||||
type documentVersionConnectionResolver struct{ *Resolver }
|
||||
type documentVersionSignatureResolver struct{ *Resolver }
|
||||
type documentVersionSignatureConnectionResolver struct{ *Resolver }
|
||||
type electronicSignatureResolver struct{ *Resolver }
|
||||
type evidenceResolver struct{ *Resolver }
|
||||
type evidenceConnectionResolver struct{ *Resolver }
|
||||
type fileResolver struct{ *Resolver }
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/baseurl"
|
||||
"go.probo.inc/probo/pkg/esign"
|
||||
"go.probo.inc/probo/pkg/iam"
|
||||
"go.probo.inc/probo/pkg/securecookie"
|
||||
"go.probo.inc/probo/pkg/server/api/authn"
|
||||
@@ -28,11 +29,12 @@ import (
|
||||
"go.probo.inc/probo/pkg/trust"
|
||||
)
|
||||
|
||||
func NewGraphQLHandler(iamSvc *iam.Service, trustSvc *trust.Service, logger *log.Logger, baseURL *baseurl.BaseURL, cookieConfig securecookie.Config) http.Handler {
|
||||
func NewGraphQLHandler(iamSvc *iam.Service, trustSvc *trust.Service, esignSvc *esign.Service, logger *log.Logger, baseURL *baseurl.BaseURL, cookieConfig securecookie.Config) http.Handler {
|
||||
config := schema.Config{
|
||||
Resolvers: &Resolver{
|
||||
iam: iamSvc,
|
||||
trust: trustSvc,
|
||||
esign: esignSvc,
|
||||
logger: logger,
|
||||
baseURL: baseURL,
|
||||
sessionCookie: authn.NewCookie(&cookieConfig),
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/baseurl"
|
||||
"go.probo.inc/probo/pkg/esign"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/iam"
|
||||
"go.probo.inc/probo/pkg/securecookie"
|
||||
@@ -46,6 +47,7 @@ type (
|
||||
|
||||
Resolver struct {
|
||||
trust *trust.Service
|
||||
esign *esign.Service
|
||||
logger *log.Logger
|
||||
iam *iam.Service
|
||||
sessionCookie *authn.Cookie
|
||||
@@ -75,6 +77,7 @@ func NewMux(
|
||||
logger *log.Logger,
|
||||
iamSvc *iam.Service,
|
||||
trustSvc *trust.Service,
|
||||
esignSvc *esign.Service,
|
||||
cookieConfig securecookie.Config,
|
||||
baseURL *baseurl.BaseURL,
|
||||
) *chi.Mux {
|
||||
@@ -84,7 +87,7 @@ func NewMux(
|
||||
r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig))
|
||||
r.Use(compliancepage.NewMembershipMiddleware(trustSvc, logger))
|
||||
|
||||
graphqlHandler := NewGraphQLHandler(iamSvc, trustSvc, logger, baseURL, cookieConfig)
|
||||
graphqlHandler := NewGraphQLHandler(iamSvc, trustSvc, esignSvc, logger, baseURL, cookieConfig)
|
||||
|
||||
r.Handle("/graphql", graphqlHandler)
|
||||
|
||||
|
||||
@@ -496,6 +496,7 @@ type TrustCenter implements Node {
|
||||
darkLogoFileUrl: String @goField(forceResolver: true)
|
||||
ndaFileName: String
|
||||
ndaFileUrl: String @goField(forceResolver: true)
|
||||
ndaSignature: ElectronicSignature @goField(forceResolver: true)
|
||||
organization: Organization! @goField(forceResolver: true)
|
||||
isViewerMember: Boolean! @goField(forceResolver: true)
|
||||
hasAcceptedNonDisclosureAgreement: Boolean! @goField(forceResolver: true)
|
||||
@@ -608,6 +609,143 @@ type AcceptNonDisclosureAgreementPayload {
|
||||
success: Boolean!
|
||||
}
|
||||
|
||||
# Electronic Signature
|
||||
|
||||
enum ElectronicSignatureStatus
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatus"
|
||||
) {
|
||||
PENDING
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusPending"
|
||||
)
|
||||
ACCEPTED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusAccepted"
|
||||
)
|
||||
PROCESSING
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusProcessing"
|
||||
)
|
||||
COMPLETED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusCompleted"
|
||||
)
|
||||
FAILED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureStatusFailed"
|
||||
)
|
||||
}
|
||||
|
||||
enum ElectronicSignatureDocumentType
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentType"
|
||||
) {
|
||||
NDA
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeNDA"
|
||||
)
|
||||
DPA
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeDPA"
|
||||
)
|
||||
MSA
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeMSA"
|
||||
)
|
||||
SOW
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeSOW"
|
||||
)
|
||||
SLA
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeSLA"
|
||||
)
|
||||
TOS
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeTOS"
|
||||
)
|
||||
PRIVACY_POLICY
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypePrivacyPolicy"
|
||||
)
|
||||
OTHER
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeOther"
|
||||
)
|
||||
}
|
||||
|
||||
enum ElectronicSignatureEventType
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventType"
|
||||
) {
|
||||
DOCUMENT_VIEWED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeDocumentViewed"
|
||||
)
|
||||
CONSENT_GIVEN
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeConsentGiven"
|
||||
)
|
||||
FULL_NAME_TYPED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeFullNameTyped"
|
||||
)
|
||||
SIGNATURE_ACCEPTED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeSignatureAccepted"
|
||||
)
|
||||
SIGNATURE_COMPLETED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeSignatureCompleted"
|
||||
)
|
||||
SEAL_COMPUTED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeSealComputed"
|
||||
)
|
||||
TIMESTAMP_REQUESTED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeTimestampRequested"
|
||||
)
|
||||
CERTIFICATE_GENERATED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeCertificateGenerated"
|
||||
)
|
||||
PROCESSING_ERROR
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureEventTypeProcessingError"
|
||||
)
|
||||
}
|
||||
|
||||
type ElectronicSignature implements Node {
|
||||
id: ID!
|
||||
status: ElectronicSignatureStatus!
|
||||
documentType: ElectronicSignatureDocumentType!
|
||||
consentText: String!
|
||||
lastError: String
|
||||
signedAt: Datetime
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
}
|
||||
|
||||
input AcceptElectronicSignatureInput {
|
||||
signatureId: ID!
|
||||
fullName: String!
|
||||
}
|
||||
|
||||
type AcceptElectronicSignaturePayload {
|
||||
signature: ElectronicSignature!
|
||||
}
|
||||
|
||||
input RecordSigningEventInput {
|
||||
signatureId: ID!
|
||||
eventType: ElectronicSignatureEventType!
|
||||
}
|
||||
|
||||
type RecordSigningEventPayload {
|
||||
success: Boolean!
|
||||
}
|
||||
|
||||
type Query {
|
||||
viewer: Identity
|
||||
node(id: ID!): Node!
|
||||
@@ -647,4 +785,12 @@ type Mutation {
|
||||
requestTrustCenterFileAccess(
|
||||
input: RequestTrustCenterFileAccessInput!
|
||||
): RequestAccessesPayload! @session(required: PRESENT)
|
||||
|
||||
acceptElectronicSignature(
|
||||
input: AcceptElectronicSignatureInput!
|
||||
): AcceptElectronicSignaturePayload @session(required: PRESENT)
|
||||
|
||||
recordSigningEvent(
|
||||
input: RecordSigningEventInput!
|
||||
): RecordSigningEventPayload @session(required: PRESENT)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
32
pkg/server/api/trust/v1/types/electronic_signature.go
Normal file
32
pkg/server/api/trust/v1/types/electronic_signature.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package types
|
||||
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func NewElectronicSignature(es *coredata.ElectronicSignature) *ElectronicSignature {
|
||||
return &ElectronicSignature{
|
||||
ID: es.ID,
|
||||
Status: es.Status,
|
||||
DocumentType: es.DocumentType,
|
||||
ConsentText: es.ConsentText,
|
||||
LastError: es.LastError,
|
||||
SignedAt: es.SignedAt,
|
||||
CreatedAt: es.CreatedAt,
|
||||
UpdatedAt: es.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,15 @@ type Node interface {
|
||||
GetID() gid.GID
|
||||
}
|
||||
|
||||
type AcceptElectronicSignatureInput struct {
|
||||
SignatureID gid.GID `json:"signatureId"`
|
||||
FullName string `json:"fullName"`
|
||||
}
|
||||
|
||||
type AcceptElectronicSignaturePayload struct {
|
||||
Signature *ElectronicSignature `json:"signature"`
|
||||
}
|
||||
|
||||
type AcceptNonDisclosureAgreementInput struct {
|
||||
FullName string `json:"fullName"`
|
||||
}
|
||||
@@ -64,6 +73,20 @@ type DocumentEdge struct {
|
||||
Node *Document `json:"node"`
|
||||
}
|
||||
|
||||
type ElectronicSignature struct {
|
||||
ID gid.GID `json:"id"`
|
||||
Status coredata.ElectronicSignatureStatus `json:"status"`
|
||||
DocumentType coredata.ElectronicSignatureDocumentType `json:"documentType"`
|
||||
ConsentText string `json:"consentText"`
|
||||
LastError *string `json:"lastError,omitempty"`
|
||||
SignedAt *time.Time `json:"signedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (ElectronicSignature) IsNode() {}
|
||||
func (this ElectronicSignature) GetID() gid.GID { return this.ID }
|
||||
|
||||
type ExportDocumentPDFInput struct {
|
||||
DocumentID gid.GID `json:"documentId"`
|
||||
}
|
||||
@@ -136,6 +159,15 @@ type PageInfo struct {
|
||||
type Query struct {
|
||||
}
|
||||
|
||||
type RecordSigningEventInput struct {
|
||||
SignatureID gid.GID `json:"signatureId"`
|
||||
EventType coredata.ElectronicSignatureEventType `json:"eventType"`
|
||||
}
|
||||
|
||||
type RecordSigningEventPayload struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
ID gid.GID `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
@@ -178,6 +210,7 @@ type TrustCenter struct {
|
||||
DarkLogoFileURL *string `json:"darkLogoFileUrl,omitempty"`
|
||||
NdaFileName *string `json:"ndaFileName,omitempty"`
|
||||
NdaFileURL *string `json:"ndaFileUrl,omitempty"`
|
||||
NdaSignature *ElectronicSignature `json:"ndaSignature,omitempty"`
|
||||
Organization *Organization `json:"organization"`
|
||||
IsViewerMember bool `json:"isViewerMember"`
|
||||
HasAcceptedNonDisclosureAgreement bool `json:"hasAcceptedNonDisclosureAgreement"`
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/esign"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/iam"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
@@ -648,6 +649,71 @@ func (r *mutationResolver) RequestTrustCenterFileAccess(ctx context.Context, inp
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AcceptElectronicSignature is the resolver for the acceptElectronicSignature field.
|
||||
func (r *mutationResolver) AcceptElectronicSignature(ctx context.Context, input types.AcceptElectronicSignatureInput) (*types.AcceptElectronicSignaturePayload, error) {
|
||||
identity := authn.IdentityFromContext(ctx)
|
||||
if identity == nil {
|
||||
return nil, gqlutils.Unauthenticatedf(ctx, "unauthenticated")
|
||||
}
|
||||
|
||||
httpReq := gqlutils.HTTPRequestFromContext(ctx)
|
||||
|
||||
if _, err := r.iam.AuthService.UpdateIdentity(ctx, identity.ID, input.FullName); err != nil {
|
||||
var errNotFound *iam.ErrIdentityNotFound
|
||||
if errors.As(err, &errNotFound) {
|
||||
return nil, gqlutils.NotFound(ctx, err)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot update identity", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
if err := r.esign.AcceptSignature(ctx, &esign.AcceptSignatureRequest{
|
||||
SignatureID: input.SignatureID,
|
||||
SignerFullName: input.FullName,
|
||||
SignerEmail: identity.EmailAddress,
|
||||
SignerIPAddr: httpReq.RemoteAddr,
|
||||
SignerUA: httpReq.UserAgent(),
|
||||
}); err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot accept electronic signature", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
sig, err := r.esign.LoadSignatureByID(ctx, input.SignatureID)
|
||||
if err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot load electronic signature", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.AcceptElectronicSignaturePayload{
|
||||
Signature: types.NewElectronicSignature(sig),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RecordSigningEvent is the resolver for the recordSigningEvent field.
|
||||
func (r *mutationResolver) RecordSigningEvent(ctx context.Context, input types.RecordSigningEventInput) (*types.RecordSigningEventPayload, error) {
|
||||
identity := authn.IdentityFromContext(ctx)
|
||||
if identity == nil {
|
||||
return nil, gqlutils.Unauthenticatedf(ctx, "unauthenticated")
|
||||
}
|
||||
|
||||
httpReq := gqlutils.HTTPRequestFromContext(ctx)
|
||||
|
||||
if err := r.esign.RecordEvent(ctx, &esign.RecordEventRequest{
|
||||
SignatureID: input.SignatureID,
|
||||
EventType: input.EventType,
|
||||
EventSource: coredata.ElectronicSignatureEventSourceClient,
|
||||
ActorEmail: identity.EmailAddress,
|
||||
ActorIPAddr: httpReq.RemoteAddr,
|
||||
ActorUA: httpReq.UserAgent(),
|
||||
}); err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot record signing event", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.RecordSigningEventPayload{Success: true}, nil
|
||||
}
|
||||
|
||||
// LogoURL is the resolver for the logoUrl field.
|
||||
func (r *organizationResolver) LogoURL(ctx context.Context, obj *types.Organization) (*string, error) {
|
||||
trustService := r.TrustService(ctx, obj.ID.TenantID())
|
||||
@@ -868,6 +934,32 @@ func (r *trustCenterResolver) NdaFileURL(ctx context.Context, obj *types.TrustCe
|
||||
return &fileURL, nil
|
||||
}
|
||||
|
||||
// NdaSignature is the resolver for the ndaSignature field.
|
||||
func (r *trustCenterResolver) NdaSignature(ctx context.Context, obj *types.TrustCenter) (*types.ElectronicSignature, error) {
|
||||
identity := authn.IdentityFromContext(ctx)
|
||||
if identity == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
trustCenter := compliancepage.CompliancePageFromContext(ctx)
|
||||
if trustCenter == nil || trustCenter.NonDisclosureAgreementFileID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sig, err := r.esign.LoadSignatureByOrgEmailAndDocType(
|
||||
ctx,
|
||||
trustCenter.OrganizationID,
|
||||
identity.EmailAddress.String(),
|
||||
coredata.ElectronicSignatureDocumentTypeNDA,
|
||||
*trustCenter.NonDisclosureAgreementFileID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return types.NewElectronicSignature(sig), nil
|
||||
}
|
||||
|
||||
// Organization is the resolver for the organization field.
|
||||
func (r *trustCenterResolver) Organization(ctx context.Context, obj *types.TrustCenter) (*types.Organization, error) {
|
||||
return obj.Organization, nil
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"go.probo.inc/probo/pkg/agents"
|
||||
"go.probo.inc/probo/pkg/baseurl"
|
||||
"go.probo.inc/probo/pkg/connector"
|
||||
"go.probo.inc/probo/pkg/esign"
|
||||
"go.probo.inc/probo/pkg/iam"
|
||||
"go.probo.inc/probo/pkg/probo"
|
||||
"go.probo.inc/probo/pkg/securecookie"
|
||||
@@ -43,6 +44,7 @@ type Config struct {
|
||||
Probo *probo.Service
|
||||
IAM *iam.Service
|
||||
Trust *trust.Service
|
||||
ESign *esign.Service
|
||||
Slack *slack.Service
|
||||
Cookie securecookie.Config
|
||||
TokenSecret string
|
||||
@@ -70,6 +72,7 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
Probo: cfg.Probo,
|
||||
IAM: cfg.IAM,
|
||||
Trust: cfg.Trust,
|
||||
ESign: cfg.ESign,
|
||||
Slack: cfg.Slack,
|
||||
Cookie: cfg.Cookie,
|
||||
TokenSecret: cfg.TokenSecret,
|
||||
|
||||
@@ -22,8 +22,10 @@ import (
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/packages/emails"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/crypto/cipher"
|
||||
"go.probo.inc/probo/pkg/esign"
|
||||
"go.probo.inc/probo/pkg/filemanager"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/html2pdf"
|
||||
@@ -43,6 +45,7 @@ type (
|
||||
slackSigningSecret string
|
||||
baseURL string
|
||||
iam *iam.Service
|
||||
esign *esign.Service
|
||||
html2pdfConverter *html2pdf.Converter
|
||||
fileManager *filemanager.Service
|
||||
logger *log.Logger
|
||||
@@ -58,6 +61,7 @@ type (
|
||||
encryptionKey cipher.EncryptionKey
|
||||
baseURL string
|
||||
iam *iam.Service
|
||||
esign *esign.Service
|
||||
html2pdfConverter *html2pdf.Converter
|
||||
fileManager *filemanager.Service
|
||||
logger *log.Logger
|
||||
@@ -83,6 +87,7 @@ func NewService(
|
||||
encryptionKey cipher.EncryptionKey,
|
||||
slackSigningSecret string,
|
||||
iam *iam.Service,
|
||||
esignSvc *esign.Service,
|
||||
html2pdfConverter *html2pdf.Converter,
|
||||
fileManagerService *filemanager.Service,
|
||||
logger *log.Logger,
|
||||
@@ -96,6 +101,7 @@ func NewService(
|
||||
slackSigningSecret: slackSigningSecret,
|
||||
baseURL: baseURL,
|
||||
iam: iam,
|
||||
esign: esignSvc,
|
||||
html2pdfConverter: html2pdfConverter,
|
||||
fileManager: fileManagerService,
|
||||
logger: logger,
|
||||
@@ -113,6 +119,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
|
||||
encryptionKey: s.encryptionKey,
|
||||
baseURL: s.baseURL,
|
||||
iam: s.iam,
|
||||
esign: s.esign,
|
||||
html2pdfConverter: s.html2pdfConverter,
|
||||
fileManager: s.fileManager,
|
||||
logger: s.logger,
|
||||
@@ -253,6 +260,21 @@ func (s *Service) GetCustomDomainByOrganizationID(ctx context.Context, organizat
|
||||
return customDomain, err
|
||||
}
|
||||
|
||||
// EmailPresenterConfigByOrganizationID resolves the emails.PresenterConfig for
|
||||
// the trust center that belongs to the given organization. This is used by the
|
||||
// esign certificate worker which needs per-org branding at render time.
|
||||
func (s *Service) EmailPresenterConfigByOrganizationID(ctx context.Context, orgID gid.GID) (emails.PresenterConfig, error) {
|
||||
var trustCenter coredata.TrustCenter
|
||||
scope := coredata.NewScopeFromObjectID(orgID)
|
||||
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||
return trustCenter.LoadByOrganizationID(ctx, conn, scope, orgID)
|
||||
})
|
||||
if err != nil {
|
||||
return emails.PresenterConfig{}, fmt.Errorf("cannot load trust center for org %s: %w", orgID, err)
|
||||
}
|
||||
return s.WithTenant(orgID.TenantID()).TrustCenters.EmailPresenterConfig(ctx, trustCenter.ID)
|
||||
}
|
||||
|
||||
func (s *Service) GetMembershipByCompliancePageIDAndEmail(ctx context.Context, compliancePageID gid.GID, email mail.Addr) (*coredata.TrustCenterAccess, error) {
|
||||
membership := &coredata.TrustCenterAccess{}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/packages/emails"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/esign"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/iam"
|
||||
"go.probo.inc/probo/pkg/mail"
|
||||
@@ -157,6 +158,23 @@ func (s TrustCenterAccessService) Request(
|
||||
if err := access.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert trust center access: %w", err)
|
||||
}
|
||||
|
||||
// Create PENDING electronic signature when NDA is configured.
|
||||
if trustCenter.NonDisclosureAgreementFileID != nil && s.svc.esign != nil {
|
||||
_, err := s.svc.esign.CreateSignature(
|
||||
ctx,
|
||||
tx,
|
||||
&esign.CreateSignatureRequest{
|
||||
OrganizationID: access.OrganizationID,
|
||||
DocumentType: coredata.ElectronicSignatureDocumentTypeNDA,
|
||||
FileID: *trustCenter.NonDisclosureAgreementFileID,
|
||||
SignerEmail: access.Email,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create pending signature: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var existingAccesses coredata.TrustCenterDocumentAccesses
|
||||
@@ -226,21 +244,50 @@ func (s TrustCenterAccessService) Request(
|
||||
}
|
||||
|
||||
func (s TrustCenterAccessService) HasAcceptedNonDisclosureAgreement(ctx context.Context, trustCenterID gid.GID, email mail.Addr) (bool, error) {
|
||||
access := &coredata.TrustCenterAccess{}
|
||||
var access coredata.TrustCenterAccess
|
||||
err := s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||
err := access.LoadByTrustCenterIDAndEmail(ctx, conn, s.svc.scope, trustCenterID, email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load trust center access: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return access.LoadByTrustCenterIDAndEmail(ctx, conn, s.svc.scope, trustCenterID, email)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return access.HasAcceptedNonDisclosureAgreement, nil
|
||||
// Legacy path: check the old boolean field.
|
||||
if access.HasAcceptedNonDisclosureAgreement {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// New path: check for an electronic signature in ACCEPTED or later status.
|
||||
if s.svc.esign != nil {
|
||||
trustCenter := &coredata.TrustCenter{}
|
||||
err := s.svc.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||
return trustCenter.LoadByID(ctx, conn, s.svc.scope, trustCenterID)
|
||||
})
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if trustCenter.NonDisclosureAgreementFileID != nil {
|
||||
sig, err := s.svc.esign.LoadSignatureByOrgEmailAndDocType(
|
||||
ctx,
|
||||
trustCenter.OrganizationID,
|
||||
email.String(),
|
||||
coredata.ElectronicSignatureDocumentTypeNDA,
|
||||
*trustCenter.NonDisclosureAgreementFileID,
|
||||
)
|
||||
if err == nil {
|
||||
switch sig.Status {
|
||||
case coredata.ElectronicSignatureStatusAccepted,
|
||||
coredata.ElectronicSignatureStatusProcessing,
|
||||
coredata.ElectronicSignatureStatusCompleted:
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type AcceptNDARequest struct {
|
||||
|
||||
Reference in New Issue
Block a user