Skip to content

Commit 459b406

Browse files
authored
feat: support template permanent link (#2362)
feat: jump to default url automatically relative issueId: T1412
1 parent 2b830fb commit 459b406

File tree

22 files changed

+610
-66
lines changed

22 files changed

+610
-66
lines changed

apps/nestjs-backend/src/features/base/base.service.ts

Lines changed: 93 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,62 @@ export class BaseService {
503503
);
504504
}
505505

506+
private async permanentEmptyBaseRelatedData(baseId: string) {
507+
return await this.prismaService.$tx(
508+
async (prisma) => {
509+
const tables = await prisma.tableMeta.findMany({
510+
where: { baseId },
511+
select: { id: true },
512+
});
513+
const tableIds = tables.map(({ id }) => id);
514+
515+
await this.dropBaseTable(tableIds);
516+
await this.tableOpenApiService.cleanReferenceFieldIds(tableIds);
517+
await this.tableOpenApiService.cleanTablesRelatedData(baseId, tableIds);
518+
await this.cleanBaseRelatedDataWithoutBase(baseId);
519+
await this.cleanRelativeNodesData(baseId);
520+
},
521+
{
522+
timeout: this.thresholdConfig.bigTransactionTimeout,
523+
}
524+
);
525+
}
526+
527+
private async cleanBaseRelatedDataWithoutBase(baseId: string) {
528+
// delete collaborators for base
529+
await this.prismaService.txClient().collaborator.deleteMany({
530+
where: { resourceId: baseId, resourceType: CollaboratorType.Base },
531+
});
532+
533+
// delete invitation for base
534+
await this.prismaService.txClient().invitation.deleteMany({
535+
where: { baseId },
536+
});
537+
538+
// delete invitation record for base
539+
await this.prismaService.txClient().invitationRecord.deleteMany({
540+
where: { baseId },
541+
});
542+
543+
// delete trash for base
544+
await this.prismaService.txClient().trash.deleteMany({
545+
where: {
546+
resourceId: baseId,
547+
resourceType: ResourceType.Base,
548+
},
549+
});
550+
}
551+
552+
private async cleanRelativeNodesData(baseId: string) {
553+
const prisma = this.prismaService.txClient();
554+
await prisma.baseNode.deleteMany({
555+
where: { baseId },
556+
});
557+
await prisma.baseNodeFolder.deleteMany({
558+
where: { baseId },
559+
});
560+
}
561+
506562
async dropBase(baseId: string, tableIds: string[]) {
507563
const sql = this.dbProvider.dropSchema(baseId);
508564
if (sql) {
@@ -511,6 +567,10 @@ export class BaseService {
511567
await this.tableOpenApiService.dropTables(tableIds);
512568
}
513569

570+
async dropBaseTable(tableIds: string[]) {
571+
await this.tableOpenApiService.dropTables(tableIds);
572+
}
573+
514574
async cleanBaseRelatedData(baseId: string) {
515575
// delete collaborators for base
516576
await this.prismaService.txClient().collaborator.deleteMany({
@@ -610,11 +670,13 @@ export class BaseService {
610670
const prisma = this.prismaService.txClient();
611671
const template = await prisma.template.findFirst({
612672
where: { baseId },
613-
select: { id: true },
673+
select: { id: true, snapshot: true },
614674
});
615675
const { title, description, cover, nodes, includeData } = publishBaseRo;
616676

617-
const snapshot = await this.createSnapshot(baseId, nodes, includeData);
677+
const snapshotBaseId = template?.snapshot ? JSON.parse(template.snapshot).baseId : undefined;
678+
679+
const snapshot = await this.createSnapshot(baseId, nodes, includeData, snapshotBaseId);
618680

619681
// Calculate snapshotActiveNodeId and defaultUrl
620682
const snapshotActiveNodeId = publishBaseRo.defaultActiveNodeId
@@ -632,7 +694,7 @@ export class BaseService {
632694

633695
// if already published, update template
634696
if (template) {
635-
await prisma.template.update({
697+
const updatedTemplate = await prisma.template.update({
636698
where: { id: template.id },
637699
data: {
638700
name: title,
@@ -646,24 +708,39 @@ export class BaseService {
646708
}),
647709
publishInfo,
648710
},
711+
select: {
712+
id: true,
713+
},
649714
});
650715
return {
651716
baseId: snapshot.baseId,
652717
defaultUrl,
718+
permalink: `/t/${updatedTemplate.id}`,
653719
};
654720
}
655721

656722
// if the base is not published, create a template
657723
// publish snapshot
658-
await this.createTemplateBySnapshot(baseId, snapshot, publishBaseRo, publishInfo);
724+
const newTemplate = await this.createTemplateBySnapshot(
725+
baseId,
726+
snapshot,
727+
publishBaseRo,
728+
publishInfo
729+
);
659730

660731
return {
661732
baseId: snapshot.baseId,
662733
defaultUrl,
734+
permalink: `/t/${newTemplate.id}`,
663735
};
664736
}
665737

666-
private async createSnapshot(baseId: string, nodes?: string[], includeData?: boolean) {
738+
private async createSnapshot(
739+
baseId: string,
740+
nodes?: string[],
741+
includeData?: boolean,
742+
existedBaseId?: string
743+
) {
667744
const prisma = this.prismaService.txClient();
668745
const { id: templateSpaceId } = await prisma.space.findFirstOrThrow({
669746
where: {
@@ -680,6 +757,11 @@ export class BaseService {
680757
},
681758
});
682759

760+
if (existedBaseId) {
761+
// delete some related data
762+
await this.cleanTemplateRelatedData(existedBaseId);
763+
}
764+
683765
const {
684766
base: { id, spaceId, name },
685767
nodeIdMap,
@@ -690,26 +772,12 @@ export class BaseService {
690772
withRecords: includeData ?? true,
691773
name: base?.name,
692774
nodes,
775+
baseId: existedBaseId,
693776
},
694777
false,
695778
true
696779
);
697780

698-
// if the base is already published, delete the former base
699-
const template = await prisma.template.findUnique({
700-
where: {
701-
baseId: baseId,
702-
},
703-
select: {
704-
snapshot: true,
705-
},
706-
});
707-
708-
if (template && template.snapshot) {
709-
const { baseId } = JSON.parse(template.snapshot);
710-
await this.cleanTemplateRelatedData(baseId);
711-
}
712-
713781
return {
714782
baseId: id,
715783
spaceId,
@@ -719,7 +787,7 @@ export class BaseService {
719787
}
720788

721789
async cleanTemplateRelatedData(baseId: string) {
722-
await this.permanentDeleteBase(baseId, true);
790+
await this.permanentEmptyBaseRelatedData(baseId);
723791
}
724792

725793
private async createTemplateBySnapshot(
@@ -754,7 +822,7 @@ export class BaseService {
754822

755823
const finalOrder = isNumber(order._max.order) ? order._max.order + 1 : 1;
756824

757-
await prisma.template.create({
825+
return await prisma.template.create({
758826
data: {
759827
id: templateId,
760828
name: title,
@@ -772,6 +840,9 @@ export class BaseService {
772840
}),
773841
publishInfo,
774842
},
843+
select: {
844+
id: true,
845+
},
775846
});
776847
}
777848
}

apps/nestjs-backend/src/features/next/next.controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export class NextController {
3737
'enterprise/?*',
3838
'unsubscribe/?*',
3939
'integrations/authorize/?*',
40+
't/?*',
4041
])
4142
public async home(@Req() req: express.Request, @Res() res: express.Response) {
4243
await this.nextService.server.getRequestHandler()(req, res);

apps/nestjs-backend/src/features/template/template-open-api.controller.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,14 @@ import { ZodValidationPipe } from '../../zod.validation.pipe';
1919
import { Permissions } from '../auth/decorators/permissions.decorator';
2020
import { Public } from '../auth/decorators/public.decorator';
2121
import { TemplateOpenApiService } from './template-open-api.service';
22+
import { TemplatePermalinkService } from './template-permalink.service';
2223

2324
@Controller('api/template')
2425
export class TemplateOpenApiController {
25-
constructor(private readonly templateOpenApiService: TemplateOpenApiService) {}
26+
constructor(
27+
private readonly templateOpenApiService: TemplateOpenApiService,
28+
private readonly templatePermalinkService: TemplatePermalinkService
29+
) {}
2630

2731
@Get()
2832
@Permissions('instance|update')
@@ -141,4 +145,10 @@ export class TemplateOpenApiController {
141145
async incrementTemplateVisitCount(@Param('templateId') templateId: string) {
142146
return this.templateOpenApiService.incrementTemplateVisitCount(templateId);
143147
}
148+
149+
@Public()
150+
@Get('/permalink/:identifier')
151+
async getTemplatePermalink(@Param('identifier') identifier: string) {
152+
return await this.templatePermalinkService.resolvePermalink(identifier);
153+
}
144154
}

apps/nestjs-backend/src/features/template/template-open-api.module.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import { AttachmentsStorageModule } from '../attachments/attachments-storage.mod
33
import { BaseModule } from '../base/base.module';
44
import { TemplateOpenApiController } from './template-open-api.controller';
55
import { TemplateOpenApiService } from './template-open-api.service';
6+
import { TemplatePermalinkService } from './template-permalink.service';
67

78
@Module({
89
imports: [BaseModule, AttachmentsStorageModule],
910
controllers: [TemplateOpenApiController],
10-
providers: [TemplateOpenApiService],
11-
exports: [TemplateOpenApiService],
11+
providers: [TemplateOpenApiService, TemplatePermalinkService],
12+
exports: [TemplateOpenApiService, TemplatePermalinkService],
1213
})
1314
export class TemplateOpenApiModule {}

apps/nestjs-backend/src/features/template/template-open-api.service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { PerformanceCacheService, PerformanceCache } from '../../performance-cac
2020
import {
2121
generateTemplateCacheKeyByBaseId,
2222
generateTemplateCategoryCacheKey,
23+
generateTemplatePermalinkCacheKey,
2324
} from '../../performance-cache/generate-keys';
2425
import type { IClsStore } from '../../types/cls';
2526
import { updateOrder } from '../../utils/update-order';
@@ -203,6 +204,8 @@ export class TemplateOpenApiService {
203204
if (res.baseId) {
204205
await this.performanceCacheService.del(generateTemplateCacheKeyByBaseId(res.baseId));
205206
}
207+
// Clear permalink cache
208+
await this.performanceCacheService.del(generateTemplatePermalinkCacheKey(templateId));
206209
return res;
207210
});
208211
}
@@ -242,6 +245,8 @@ export class TemplateOpenApiService {
242245
if (res.baseId) {
243246
await this.performanceCacheService.del(generateTemplateCacheKeyByBaseId(res.baseId));
244247
}
248+
// Clear permalink cache when template is updated (especially when publish status changes)
249+
await this.performanceCacheService.del(generateTemplatePermalinkCacheKey(templateId));
245250
return res;
246251
});
247252
}
@@ -317,6 +322,8 @@ export class TemplateOpenApiService {
317322
if (res.baseId) {
318323
await this.performanceCacheService.del(generateTemplateCacheKeyByBaseId(res.baseId));
319324
}
325+
// Clear permalink cache when snapshot is updated
326+
await this.performanceCacheService.del(generateTemplatePermalinkCacheKey(templateId));
320327
return res;
321328
});
322329
},
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { IdPrefix, HttpErrorCode } from '@teable/core';
3+
import { PrismaService } from '@teable/db-main-prisma';
4+
import type { ITemplatePermalinkVo } from '@teable/openapi';
5+
import { CustomHttpException } from '../../custom.exception';
6+
import { PerformanceCache, PerformanceCacheService } from '../../performance-cache';
7+
import { generateTemplatePermalinkCacheKey } from '../../performance-cache/generate-keys';
8+
9+
@Injectable()
10+
export class TemplatePermalinkService {
11+
private logger = new Logger(TemplatePermalinkService.name);
12+
13+
constructor(
14+
private readonly prismaService: PrismaService,
15+
private readonly performanceCacheService: PerformanceCacheService
16+
) {}
17+
18+
@PerformanceCache({
19+
ttl: 86400, // 1 day (24 hours)
20+
keyGenerator: (identifier: string) => generateTemplatePermalinkCacheKey(identifier),
21+
})
22+
async resolvePermalink(identifier: string): Promise<ITemplatePermalinkVo> {
23+
const prisma = this.prismaService.txClient();
24+
25+
if (!identifier.startsWith(IdPrefix.Template)) {
26+
throw new CustomHttpException('Invalid identifier', HttpErrorCode.NOT_FOUND);
27+
}
28+
29+
// 1. Find template by ID
30+
const template = await prisma.template.findUnique({
31+
where: { id: identifier },
32+
select: {
33+
publishInfo: true,
34+
snapshot: true,
35+
isPublished: true,
36+
id: true,
37+
},
38+
});
39+
40+
// 2. Validate template exists
41+
if (!template) {
42+
throw new CustomHttpException('Template not found', HttpErrorCode.NOT_FOUND);
43+
}
44+
45+
// 3. Check if template is published
46+
if (!template.isPublished) {
47+
throw new CustomHttpException('Template is not published', HttpErrorCode.RESTRICTED_RESOURCE);
48+
}
49+
50+
// 4. Parse snapshot and publishInfo
51+
const snapshot = template.snapshot ? JSON.parse(template.snapshot) : {};
52+
const publishInfo = template.publishInfo as { defaultUrl?: string } | null;
53+
const snapshotBaseId = snapshot.baseId;
54+
55+
if (!snapshotBaseId) {
56+
throw new CustomHttpException(
57+
'Template snapshot is invalid',
58+
HttpErrorCode.UNPROCESSABLE_ENTITY
59+
);
60+
}
61+
62+
// 5. Get redirect URL from publishInfo, fallback to base homepage
63+
const defaultUrl = publishInfo?.defaultUrl;
64+
const redirectUrl = defaultUrl || `/base/${snapshotBaseId}`;
65+
66+
return {
67+
redirectUrl,
68+
};
69+
}
70+
}

apps/nestjs-backend/src/performance-cache/generate-keys.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ export function generateTemplateCategoryCacheKey() {
5454
return `template:published-category-list` as const;
5555
}
5656

57+
export function generateTemplatePermalinkCacheKey(identifier: string) {
58+
return `template:permalink:${identifier}` as const;
59+
}
60+
5761
export function generateInstanceBillableUserCountCacheKey() {
5862
return 'instance-billable-count' as const;
5963
}

apps/nestjs-backend/src/types/i18n.generated.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2919,6 +2919,8 @@ export type I18nTranslations = {
29192919
"generateToken": string;
29202920
"confirmTitle": string;
29212921
"confirmDescription": string;
2922+
"scopeTableRead": string;
2923+
"scopeFieldRead": string;
29222924
"scopeRead": string;
29232925
"scopeCreate": string;
29242926
"scopeUpdate": string;

0 commit comments

Comments
 (0)