1import { Inject, Injectable } from "@nestjs/common";
2import { PrismaService } from "../../common/prisma.service.js";
3import { AuditService } from "../../common/audit.service.js";
4import { EventBus } from "../../common/event-bus.js";
5import { ApiError } from "../../common/errors.js";
6import { randomToken } from "../../lib/crypto.js";
7import { assertCan } from "../../lib/authorize.js";
8import { loadEnv } from "../../config/env.js";
9import { EVENTS } from "../../constants.js";
10import { NotificationsService } from "../notifications/notifications.service.js";
11import { workspaceOrgPolicy } from "../org/org-policy.service.js";
12
13
19@Injectable()
20export class ShareService {
21 constructor(
22 @Inject(PrismaService) private readonly prisma: PrismaService,
23 @Inject(AuditService) private readonly audit: AuditService,
24 @Inject(EventBus) private readonly events: EventBus,
25 @Inject(NotificationsService) private readonly notifications: NotificationsService
26 ) {}
27
28
33 async create(
34 userId: string,
35 input: {
36 workspaceId: string;
37 targetType: "page" | "database";
38 targetId: string;
39 kind: "link" | "public" | "invite";
40 level: "VIEW" | "COMMENT" | "EDIT";
41 email?: string;
42 expiresAt?: string;
43 },
44 req: any
45 ) {
46 const { workspaceId, targetType, targetId, kind } = input;
47 if (targetType === "database") {
48 const db = await this.prisma.database.findFirst({ where: { id: targetId, workspaceId } });
49 if (!db) throw ApiError.notFound("Database not found.");
50 await assertCan(this.prisma, { workspaceId, userId, resource: { databaseId: targetId } }, "EDIT");
51 } else {
52 const page = await this.prisma.page.findFirst({ where: { id: targetId, workspaceId } });
53 if (!page || page.deletedAt) throw ApiError.notFound("Page not found.");
54 await assertCan(this.prisma, { workspaceId, userId, resource: { pageId: targetId } }, "EDIT");
55 }
56
57
58
59
60 if (kind === "public") {
61 const { policy } = await workspaceOrgPolicy(this.prisma, workspaceId);
62 if (policy?.disableGuestPublicShare === true) {
63 throw ApiError.forbidden("Public guest sharing is disabled by your organization.", [
64 { reason: "guest_public_share_disabled" }
65 ]);
66 }
67 }
68
69 const scope = kind === "public" ? "PUBLIC" : kind === "invite" ? "USERS" : "PRIVATE";
70 const token = `sh_${randomToken(24)}`;
71 const ws = await this.prisma.workspace.findUnique({ where: { id: workspaceId }, select: { slug: true, name: true } });
72 const share = await this.prisma.share.create({
73 data: {
74 workspaceId,
75 pageId: targetType === "page" ? targetId : null,
76 recordId: targetType === "database" ? targetId : null,
77 token,
78 scope: scope as never,
79 allowComments: input.level !== "VIEW",
80 expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
81 createdBy: userId
82 }
83 });
84
85
86 await this.prisma.permission.create({
87 data: {
88 workspaceId,
89 granteeType: "share",
90 granteeId: share.id,
91 resourceType: targetType,
92 resourceId: targetId,
93 level: input.level as never
94 }
95 }).catch(() => undefined);
96
97
98
99
100 await this.audit.audit(req, req?.auth, {
101 action: "page.shared",
102 resourceType: "share",
103 resourceId: share.id,
104 workspaceId,
105 pageId: targetType === "page" ? targetId : undefined,
106 metadata: {
107 targetType,
108 targetId,
109 scope,
110 kind,
111 level: input.level,
112 email: input.email ?? null
113 }
114 });
115 await this.events.publish(EVENTS.PAGE_SHARED, {
116 workspaceId,
117 shareId: share.id,
118 targetType,
119 targetId,
120 scope
121 });
122
123
124
125
126
127 if (input.email) {
128 const grantee = await this.prisma.user.findUnique({
129 where: { email: input.email },
130 select: { id: true }
131 });
132 if (grantee && grantee.id !== userId) {
133 await this.notifications
134 .notify({
135 workspaceId,
136 userId: grantee.id,
137 type: "PAGE_SHARED",
138 title: `A ${targetType} was shared with you`,
139 body: `${ws?.name ?? "A workspace"}: ${targetType} shared via link`,
140 link: { type: targetType, id: targetId },
141 actorId: userId
142 })
143 .catch(() => undefined);
144 }
145 }
146
147 const baseUrl = loadEnv().WEB_ORIGIN;
148 const url =
149 kind === "public"
150 ? `${baseUrl}/share/${token}`
151 : `${baseUrl}/share/${token}?t=${encodeURIComponent(token)}`;
152 return {
153 share: {
154 id: share.id,
155 token,
156 scope: share.scope,
157 allowComments: share.allowComments,
158 expiresAt: share.expiresAt,
159 createdBy: share.createdBy,
160 createdAt: share.createdAt
161 },
162 url,
163 workspace: { id: workspaceId, name: ws?.name, slug: ws?.slug }
164 };
165 }
166
167
168 async list(workspaceId: string, userId: string) {
169 const shares = await this.prisma.share.findMany({
170 where: { workspaceId, createdBy: userId },
171 orderBy: { createdAt: "desc" },
172 take: 200
173 });
174 return {
175 data: shares.map((s) => ({
176 id: s.id,
177 token: s.token,
178 scope: s.scope,
179 pageId: s.pageId,
180 recordId: s.recordId,
181 allowComments: s.allowComments,
182 expiresAt: s.expiresAt,
183 createdAt: s.createdAt
184 }))
185 };
186 }
187
188 async remove(shareId: string, userId: string, req: any) {
189 const share = await this.prisma.share.findUnique({ where: { id: shareId } });
190 if (!share) throw ApiError.notFound("Share not found.");
191 if (share.createdBy !== userId) {
192 const level = await assertCan(this.prisma, { workspaceId: share.workspaceId, userId }, "FULL_ACCESS");
193 void level;
194 }
195 await this.prisma.$transaction([
196 this.prisma.share.delete({ where: { id: shareId } }),
197 this.prisma.permission.deleteMany({ where: { granteeType: "share", granteeId: shareId } })
198 ]);
199 await this.audit.audit(req, req?.auth, {
200 action: "share.revoked",
201 resourceType: "share",
202 resourceId: shareId,
203 workspaceId: share.workspaceId,
204 metadata: { token: share.token }
205 });
206 return { deleted: true };
207 }
208
209
213 async publicGet(token: string) {
214 const share = await this.prisma.share.findUnique({ where: { token } });
215 if (!share || share.scope !== "PUBLIC") return null;
216 if (share.expiresAt && share.expiresAt.getTime() < Date.now()) return null;
217
218 const workspace = await this.prisma.workspace.findUnique({
219 where: { id: share.workspaceId },
220 select: { id: true, name: true, slug: true, settings: true, deletedAt: true }
221 });
222 if (!workspace || workspace.deletedAt) return null;
223
224
225 const settings = workspace.settings as Record<string, unknown>;
226 if (settings.guestPublicShare === false) return null;
227
228 if (share.pageId) {
229 const page = await this.prisma.page.findUnique({
230 where: { id: share.pageId },
231 select: {
232 id: true, title: true, icon: true, description: true, coverUrl: true,
233 trashedAt: true, deletedAt: true
234 }
235 });
236 if (!page || page.deletedAt || page.trashedAt) return null;
237 const blocks = await this.prisma.block.findMany({
238 where: { pageId: page.id, deletedAt: null },
239 select: { id: true, type: true, content: true, properties: true, position: true },
240 orderBy: { position: "asc" },
241 take: 2000
242 });
243 return {
244 share: { id: share.id, scope: share.scope, allowComments: share.allowComments, expiresAt: share.expiresAt },
245 workspace: { id: workspace.id, name: workspace.name, slug: workspace.slug },
246 target: { type: "page" as const, page, blocks },
247 canComment: share.allowComments
248 };
249 }
250
251
252 const db = await this.prisma.database.findUnique({
253 where: { id: share.recordId ?? "" },
254 select: { id: true, title: true, icon: true, description: true, isArchived: true }
255 });
256 if (!db || db.isArchived) return null;
257 const properties = await this.prisma.databaseProperty.findMany({
258 where: { databaseId: db.id, deletedAt: null, isVisible: true },
259 select: { id: true, name: true, type: true, isTitle: true },
260 orderBy: { position: "asc" }
261 });
262 const records = await this.prisma.databaseRecord.findMany({
263 where: { databaseId: db.id, archivedAt: null },
264 select: { id: true, properties: { select: { propertyId: true, value: true } } },
265 take: 500
266 });
267 const propById = new Map(properties.map((p) => [p.id, p]));
268 const rows = records.map((r) => {
269 const values: Record<string, unknown> = {};
270 for (const rp of r.properties) {
271 const prop = propById.get(rp.propertyId);
272 if (prop) values[prop.name] = rp.value;
273 }
274 return { id: r.id, properties: values };
275 });
276 return {
277 share: { id: share.id, scope: share.scope, allowComments: share.allowComments, expiresAt: share.expiresAt },
278 workspace: { id: workspace.id, name: workspace.name, slug: workspace.slug },
279 target: { type: "database" as const, database: db, properties, records: rows },
280 canComment: share.allowComments
281 };
282 }
283}
284