ta_data_mas 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/apps/api/.prettierrc +4 -0
- package/apps/api/README.md +98 -0
- package/apps/api/drizzle/0000_right_drax.sql +87 -0
- package/apps/api/drizzle/meta/0000_snapshot.json +588 -0
- package/apps/api/drizzle/meta/_journal.json +13 -0
- package/apps/api/drizzle.config.ts +12 -0
- package/apps/api/eslint.config.mjs +35 -0
- package/apps/api/nest-cli.json +8 -0
- package/apps/api/package.json +99 -0
- package/apps/api/src/app.controller.spec.ts +25 -0
- package/apps/api/src/app.controller.ts +9 -0
- package/apps/api/src/app.module.ts +37 -0
- package/apps/api/src/app.service.ts +8 -0
- package/apps/api/src/auth/auth.controller.ts +113 -0
- package/apps/api/src/auth/auth.module.ts +15 -0
- package/apps/api/src/auth/auth.service.ts +309 -0
- package/apps/api/src/auth/decorators/current-user-decorator.ts +10 -0
- package/apps/api/src/auth/decorators/require-org-role-decorator.ts +7 -0
- package/apps/api/src/auth/dto/login.dto.ts +10 -0
- package/apps/api/src/auth/dto/register.dto.ts +14 -0
- package/apps/api/src/auth/guards/jwt-auth-guard.ts +40 -0
- package/apps/api/src/auth/guards/org-role.guards.ts +60 -0
- package/apps/api/src/db/db.module.ts +9 -0
- package/apps/api/src/db/drizzle.service.ts +16 -0
- package/apps/api/src/db/schema/index.ts +6 -0
- package/apps/api/src/db/schema/organizations.ts +30 -0
- package/apps/api/src/db/schema/projects.ts +26 -0
- package/apps/api/src/db/schema/query-history.ts +16 -0
- package/apps/api/src/db/schema/storage-buckets.ts +17 -0
- package/apps/api/src/db/schema/storage-objects.ts +18 -0
- package/apps/api/src/db/schema/users.ts +14 -0
- package/apps/api/src/main.ts +51 -0
- package/apps/api/src/members/dto/invite-member-dto.ts +6 -0
- package/apps/api/src/members/dto/update-role.dto.ts +8 -0
- package/apps/api/src/members/invite.service.ts +147 -0
- package/apps/api/src/members/members.controller.ts +56 -0
- package/apps/api/src/members/members.module.ts +13 -0
- package/apps/api/src/members/members.service.ts +85 -0
- package/apps/api/src/orgs/dto/create-org.dto.ts +8 -0
- package/apps/api/src/orgs/orgs.controller.ts +27 -0
- package/apps/api/src/orgs/orgs.module.ts +12 -0
- package/apps/api/src/orgs/orgs.service.ts +86 -0
- package/apps/api/src/project-api/project-api.controller.ts +95 -0
- package/apps/api/src/project-api/project-api.module.ts +12 -0
- package/apps/api/src/project-api/project-api.service.ts +196 -0
- package/apps/api/src/project-api/project-key.guard.ts +45 -0
- package/apps/api/src/project-api/query-parser.ts +129 -0
- package/apps/api/src/project-auth/dto/magic-link.dto.ts +7 -0
- package/apps/api/src/project-auth/dto/signin.dto.ts +10 -0
- package/apps/api/src/project-auth/dto/signup.dto.ts +11 -0
- package/apps/api/src/project-auth/project-auth-dashboard.controller.ts +35 -0
- package/apps/api/src/project-auth/project-auth.controller.ts +111 -0
- package/apps/api/src/project-auth/project-auth.module.ts +13 -0
- package/apps/api/src/project-auth/project-auth.service.ts +440 -0
- package/apps/api/src/projects/dto/create-project.dto.ts +9 -0
- package/apps/api/src/projects/projects.controller.ts +34 -0
- package/apps/api/src/projects/projects.module.ts +12 -0
- package/apps/api/src/projects/projects.service.ts +136 -0
- package/apps/api/src/realtime/realtime-socket.type.ts +14 -0
- package/apps/api/src/realtime/realtime.controller.ts +63 -0
- package/apps/api/src/realtime/realtime.gateway.ts +150 -0
- package/apps/api/src/realtime/realtime.module.ts +14 -0
- package/apps/api/src/realtime/realtime.service.ts +101 -0
- package/apps/api/src/realtime/trigger.service.ts +95 -0
- package/apps/api/src/sql-editor/dto/execute-query.dto.ts +7 -0
- package/apps/api/src/sql-editor/sql-editor.controller.ts +28 -0
- package/apps/api/src/sql-editor/sql-editor.module.ts +12 -0
- package/apps/api/src/sql-editor/sql-editor.service.ts +154 -0
- package/apps/api/src/storage/project-storage.controller.ts +140 -0
- package/apps/api/src/storage/storage.controller.ts +118 -0
- package/apps/api/src/storage/storage.module.ts +15 -0
- package/apps/api/src/storage/storage.service.ts +191 -0
- package/apps/api/src/storage/uploadthing.ts +23 -0
- package/apps/api/src/table-editor/dto/alter-table.dto.ts +15 -0
- package/apps/api/src/table-editor/dto/create-table.dto.ts +54 -0
- package/apps/api/src/table-editor/table-editor.controller.ts +117 -0
- package/apps/api/src/table-editor/table-editor.module.ts +12 -0
- package/apps/api/src/table-editor/table-editor.service.ts +366 -0
- package/apps/api/test/app.e2e-spec.ts +29 -0
- package/apps/api/test/jest-e2e.json +9 -0
- package/apps/api/tsconfig.build.json +4 -0
- package/apps/api/tsconfig.json +25 -0
- package/apps/web/.env.example +12 -0
- package/apps/web/AGENTS.md +9 -0
- package/apps/web/CLAUDE.md +1 -0
- package/apps/web/README.md +36 -0
- package/apps/web/components.json +25 -0
- package/apps/web/eslint.config.mjs +18 -0
- package/apps/web/next.config.ts +11 -0
- package/apps/web/package.json +48 -0
- package/apps/web/postcss.config.mjs +7 -0
- package/apps/web/public/file.svg +1 -0
- package/apps/web/public/globe.svg +1 -0
- package/apps/web/public/logo.svg +4 -0
- package/apps/web/public/next.svg +1 -0
- package/apps/web/public/vercel.svg +1 -0
- package/apps/web/public/window.svg +1 -0
- package/apps/web/src/app/(dashboard)/layout.tsx +33 -0
- package/apps/web/src/app/(dashboard)/organizations/(list)/layout.tsx +7 -0
- package/apps/web/src/app/(dashboard)/organizations/(list)/new/page.tsx +26 -0
- package/apps/web/src/app/(dashboard)/organizations/(list)/page.tsx +44 -0
- package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/api/page.tsx +13 -0
- package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/auth/layout.tsx +19 -0
- package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/auth/page.tsx +10 -0
- package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/auth/providers/page.tsx +19 -0
- package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/auth/url-configuration/page.tsx +24 -0
- package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/auth/users/page.tsx +13 -0
- package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/database/page.tsx +29 -0
- package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/realtime/page.tsx +28 -0
- package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/sql/page.tsx +21 -0
- package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/storage/page.tsx +28 -0
- package/apps/web/src/app/(dashboard)/organizations/[slug]/layout.tsx +20 -0
- package/apps/web/src/app/(dashboard)/organizations/[slug]/projects/page.tsx +62 -0
- package/apps/web/src/app/(dashboard)/organizations/[slug]/settings/members/page.tsx +28 -0
- package/apps/web/src/app/(dashboard)/organizations/[slug]/settings/page.tsx +10 -0
- package/apps/web/src/app/(dashboard)/settings/page.tsx +12 -0
- package/apps/web/src/app/dashboard/page.tsx +14 -0
- package/apps/web/src/app/favicon.ico +0 -0
- package/apps/web/src/app/globals.css +115 -0
- package/apps/web/src/app/layout.tsx +36 -0
- package/apps/web/src/app/login/page.tsx +199 -0
- package/apps/web/src/app/page.tsx +100 -0
- package/apps/web/src/app/register/page.tsx +218 -0
- package/apps/web/src/components/app-sidebar.tsx +301 -0
- package/apps/web/src/components/navbar.tsx +63 -0
- package/apps/web/src/components/topNav.tsx +68 -0
- package/apps/web/src/components/ui/avatar.tsx +112 -0
- package/apps/web/src/components/ui/badge.tsx +48 -0
- package/apps/web/src/components/ui/button.tsx +66 -0
- package/apps/web/src/components/ui/card.tsx +102 -0
- package/apps/web/src/components/ui/checkbox.tsx +32 -0
- package/apps/web/src/components/ui/dialog.tsx +168 -0
- package/apps/web/src/components/ui/dropdown-menu.tsx +269 -0
- package/apps/web/src/components/ui/field.tsx +238 -0
- package/apps/web/src/components/ui/input.tsx +19 -0
- package/apps/web/src/components/ui/label.tsx +24 -0
- package/apps/web/src/components/ui/resizable.tsx +50 -0
- package/apps/web/src/components/ui/scroll-area.tsx +55 -0
- package/apps/web/src/components/ui/select.tsx +192 -0
- package/apps/web/src/components/ui/separator.tsx +28 -0
- package/apps/web/src/components/ui/sheet.tsx +147 -0
- package/apps/web/src/components/ui/sidebar.tsx +703 -0
- package/apps/web/src/components/ui/skeleton.tsx +13 -0
- package/apps/web/src/components/ui/switch.tsx +33 -0
- package/apps/web/src/components/ui/table.tsx +116 -0
- package/apps/web/src/components/ui/tooltip.tsx +57 -0
- package/apps/web/src/features/api-docs/api-docs-client.tsx +227 -0
- package/apps/web/src/features/api-docs/api-docs-helpers.server.ts +86 -0
- package/apps/web/src/features/auth/action.ts +67 -0
- package/apps/web/src/features/auth/client.schema.ts +13 -0
- package/apps/web/src/features/auth/constants.ts +6 -0
- package/apps/web/src/features/auth/cookies.ts +86 -0
- package/apps/web/src/features/auth/server.schema.ts +11 -0
- package/apps/web/src/features/auth/sign-out.ts +17 -0
- package/apps/web/src/features/members/action.ts +66 -0
- package/apps/web/src/features/members/client.schema.ts +11 -0
- package/apps/web/src/features/members/constants.ts +8 -0
- package/apps/web/src/features/members/members-client.tsx +198 -0
- package/apps/web/src/features/members/members-helpers.server.ts +21 -0
- package/apps/web/src/features/members/server.schema.ts +19 -0
- package/apps/web/src/features/organization/action.ts +45 -0
- package/apps/web/src/features/organization/client.schema.ts +5 -0
- package/apps/web/src/features/organization/constants.tsx +3 -0
- package/apps/web/src/features/organization/create-org-form.tsx +66 -0
- package/apps/web/src/features/organization/organization-helpers.server.ts +35 -0
- package/apps/web/src/features/organization/server.schema.ts +10 -0
- package/apps/web/src/features/project-auth/project-auth-action.ts +78 -0
- package/apps/web/src/features/project-auth/project-auth-client.schema.ts +18 -0
- package/apps/web/src/features/project-auth/project-auth-constants.ts +8 -0
- package/apps/web/src/features/project-auth/project-auth-helpers.server.ts +39 -0
- package/apps/web/src/features/project-auth/project-auth-providers.tsx +222 -0
- package/apps/web/src/features/project-auth/project-auth-server.schema.ts +22 -0
- package/apps/web/src/features/project-auth/project-auth-shell.tsx +106 -0
- package/apps/web/src/features/project-auth/project-auth-url-config.tsx +151 -0
- package/apps/web/src/features/project-auth/project-auth-users.tsx +138 -0
- package/apps/web/src/features/projects/action.ts +46 -0
- package/apps/web/src/features/projects/client.schema.ts +9 -0
- package/apps/web/src/features/projects/constants.ts +6 -0
- package/apps/web/src/features/projects/create-project-modal.tsx +117 -0
- package/apps/web/src/features/projects/project-helpers.server.ts +38 -0
- package/apps/web/src/features/projects/server.schema.ts +10 -0
- package/apps/web/src/features/realtime/realtime-client.tsx +294 -0
- package/apps/web/src/features/realtime/realtime-helpers.server.ts +40 -0
- package/apps/web/src/features/sql-editor/sql-editor-client.tsx +324 -0
- package/apps/web/src/features/sql-editor/sql-editor-helpers.server.ts +22 -0
- package/apps/web/src/features/storage/storage-client.tsx +500 -0
- package/apps/web/src/features/storage/storage-helpers.server.ts +36 -0
- package/apps/web/src/features/table-editor/action.ts +97 -0
- package/apps/web/src/features/table-editor/add-column-dialog.tsx +184 -0
- package/apps/web/src/features/table-editor/client.schema.ts +36 -0
- package/apps/web/src/features/table-editor/create-table-drawer.tsx +542 -0
- package/apps/web/src/features/table-editor/server.schema.ts +41 -0
- package/apps/web/src/features/table-editor/table-editor-client.tsx +737 -0
- package/apps/web/src/features/table-editor/table-editor-helpers.server.ts +39 -0
- package/apps/web/src/hooks/use-mobile.ts +19 -0
- package/apps/web/src/lib/axios.ts +8 -0
- package/apps/web/src/lib/utils.ts +6 -0
- package/apps/web/src/proxy.ts +72 -0
- package/apps/web/src/server-utils/utils.ts +10 -0
- package/apps/web/tsconfig.json +34 -0
- package/package.json +22 -0
- package/packages/apiDatabase-js/package.json +39 -0
- package/packages/apiDatabase-js/src/auth/index.ts +121 -0
- package/packages/apiDatabase-js/src/client.ts +25 -0
- package/packages/apiDatabase-js/src/db/index.ts +15 -0
- package/packages/apiDatabase-js/src/db/query-builder.ts +194 -0
- package/packages/apiDatabase-js/src/index.ts +16 -0
- package/packages/apiDatabase-js/src/realtime/index.ts +84 -0
- package/packages/apiDatabase-js/src/storage/index.ts +130 -0
- package/packages/apiDatabase-js/tsconfig.json +13 -0
- package/packages/apiDatabase-js/tsdown.config.ts +20 -0
- package/packages/constants/package.json +6 -0
- package/packages/constants/src/index.ts +76 -0
- package/packages/tsconfig.json +13 -0
- package/packages/tsdown.config.ts +20 -0
- package/packages/types/package.json +6 -0
- package/packages/types/src/index.ts +254 -0
- package/pnpm-workspace.yaml +10 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
2
|
+
import { and, eq, isNull } from 'drizzle-orm';
|
|
3
|
+
import { randomBytes } from 'crypto';
|
|
4
|
+
import slugify from 'slugify';
|
|
5
|
+
import { JwtService } from '@nestjs/jwt';
|
|
6
|
+
import { ConfigService } from '@nestjs/config';
|
|
7
|
+
import { DrizzleService } from '../db/drizzle.service';
|
|
8
|
+
import { projects, organizations, orgMembers } from '../db/schema';
|
|
9
|
+
import { CreateProjectDto } from './dto/create-project.dto';
|
|
10
|
+
import { PROJECT_KEY_ROLES } from '@apiDatabase/constants';
|
|
11
|
+
|
|
12
|
+
@Injectable()
|
|
13
|
+
export class ProjectsService {
|
|
14
|
+
constructor(
|
|
15
|
+
private drizzle: DrizzleService,
|
|
16
|
+
private jwtService: JwtService,
|
|
17
|
+
private configService: ConfigService,
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
// Utils
|
|
21
|
+
private generateProjectSlug(name: string): string {
|
|
22
|
+
const base = slugify(name, { lower: true, strict: true });
|
|
23
|
+
const suffix = randomBytes(3).toString('hex');
|
|
24
|
+
return `${base}-${suffix}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private generateDbSchema(): string {
|
|
28
|
+
return `proj_${randomBytes(4).toString('hex')}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private signProjectKey(projectId: string, role: string): string {
|
|
32
|
+
return this.jwtService.sign(
|
|
33
|
+
{ projectId, role },
|
|
34
|
+
{
|
|
35
|
+
secret: this.configService.get<string>('PROJECT_JWT_SECRET'),
|
|
36
|
+
// project keys don't expire — they're rotated manually if compromised
|
|
37
|
+
expiresIn: '100y',
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async provisionSchema(dbSchema: string): Promise<void> {
|
|
43
|
+
await this.drizzle.db.execute(`CREATE SCHEMA IF NOT EXISTS "${dbSchema}"`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Queries
|
|
47
|
+
async getProjectsForOrg(orgSlug: string, userId: string) {
|
|
48
|
+
return this.drizzle.db
|
|
49
|
+
.select({
|
|
50
|
+
id: projects.id,
|
|
51
|
+
name: projects.name,
|
|
52
|
+
slug: projects.slug,
|
|
53
|
+
projectUrl: projects.projectUrl,
|
|
54
|
+
createdAt: projects.createdAt,
|
|
55
|
+
updatedAt: projects.updatedAt,
|
|
56
|
+
})
|
|
57
|
+
.from(projects)
|
|
58
|
+
.innerJoin(organizations, eq(projects.orgId, organizations.id))
|
|
59
|
+
.innerJoin(
|
|
60
|
+
orgMembers,
|
|
61
|
+
and(
|
|
62
|
+
eq(orgMembers.orgId, organizations.id),
|
|
63
|
+
eq(orgMembers.userId, userId),
|
|
64
|
+
isNull(orgMembers.removedAt),
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
.where(eq(organizations.slug, orgSlug));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async getProjectBySlug(orgSlug: string, projectSlug: string, userId: string) {
|
|
71
|
+
const [row] = await this.drizzle.db
|
|
72
|
+
.select()
|
|
73
|
+
.from(projects)
|
|
74
|
+
.innerJoin(organizations, eq(projects.orgId, organizations.id))
|
|
75
|
+
.innerJoin(
|
|
76
|
+
orgMembers,
|
|
77
|
+
and(
|
|
78
|
+
eq(orgMembers.orgId, organizations.id),
|
|
79
|
+
eq(orgMembers.userId, userId),
|
|
80
|
+
isNull(orgMembers.removedAt),
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
.where(
|
|
84
|
+
and(eq(organizations.slug, orgSlug), eq(projects.slug, projectSlug)),
|
|
85
|
+
)
|
|
86
|
+
.limit(1);
|
|
87
|
+
|
|
88
|
+
if (!row) throw new NotFoundException('Project not found');
|
|
89
|
+
return row;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async createProject(orgSlug: string, dto: CreateProjectDto) {
|
|
93
|
+
const [org] = await this.drizzle.db
|
|
94
|
+
.select()
|
|
95
|
+
.from(organizations)
|
|
96
|
+
.where(eq(organizations.slug, orgSlug))
|
|
97
|
+
.limit(1);
|
|
98
|
+
|
|
99
|
+
if (!org) throw new NotFoundException('Organization not found');
|
|
100
|
+
|
|
101
|
+
const projectSlug = this.generateProjectSlug(dto.name);
|
|
102
|
+
const dbSchema = this.generateDbSchema();
|
|
103
|
+
const projectUrl = `${this.configService.get<string>('API_URL')}/projects/${projectSlug}`;
|
|
104
|
+
|
|
105
|
+
await this.provisionSchema(dbSchema);
|
|
106
|
+
const authJwtSecret = randomBytes(32).toString('hex');
|
|
107
|
+
|
|
108
|
+
const [project] = await this.drizzle.db
|
|
109
|
+
.insert(projects)
|
|
110
|
+
.values({
|
|
111
|
+
orgId: org.id,
|
|
112
|
+
name: dto.name,
|
|
113
|
+
slug: projectSlug,
|
|
114
|
+
dbSchema,
|
|
115
|
+
projectUrl,
|
|
116
|
+
anonKey: '',
|
|
117
|
+
serviceRoleKey: '',
|
|
118
|
+
authJwtSecret,
|
|
119
|
+
})
|
|
120
|
+
.returning();
|
|
121
|
+
|
|
122
|
+
const anonKey = this.signProjectKey(project.id, PROJECT_KEY_ROLES.ANON);
|
|
123
|
+
const serviceRoleKey = this.signProjectKey(
|
|
124
|
+
project.id,
|
|
125
|
+
PROJECT_KEY_ROLES.SERVICE_ROLE,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const [updated] = await this.drizzle.db
|
|
129
|
+
.update(projects)
|
|
130
|
+
.set({ anonKey, serviceRoleKey })
|
|
131
|
+
.where(eq(projects.id, project.id))
|
|
132
|
+
.returning();
|
|
133
|
+
|
|
134
|
+
return updated;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DefaultEventsMap, Socket } from 'socket.io';
|
|
2
|
+
import type { PROJECT_KEY_ROLES } from '@apiDatabase/constants';
|
|
3
|
+
|
|
4
|
+
export interface RealtimeSocketData {
|
|
5
|
+
projectId?: string;
|
|
6
|
+
role?: (typeof PROJECT_KEY_ROLES)[keyof typeof PROJECT_KEY_ROLES];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type RealtimeSocket = Socket<
|
|
10
|
+
DefaultEventsMap,
|
|
11
|
+
DefaultEventsMap,
|
|
12
|
+
DefaultEventsMap,
|
|
13
|
+
RealtimeSocketData
|
|
14
|
+
>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Controller,
|
|
3
|
+
Delete,
|
|
4
|
+
NotFoundException,
|
|
5
|
+
Param,
|
|
6
|
+
Post,
|
|
7
|
+
UseGuards,
|
|
8
|
+
} from '@nestjs/common';
|
|
9
|
+
import { and, eq } from 'drizzle-orm';
|
|
10
|
+
import { TriggerService } from './trigger.service';
|
|
11
|
+
import { DrizzleService } from '../db/drizzle.service';
|
|
12
|
+
import { JwtAuthGuard } from '../auth/guards/jwt-auth-guard';
|
|
13
|
+
import { OrgRoleGuard } from '../auth/guards/org-role.guards';
|
|
14
|
+
import { projects, organizations } from '../db/schema';
|
|
15
|
+
|
|
16
|
+
@Controller('orgs/:slug/projects/:projectSlug/realtime')
|
|
17
|
+
@UseGuards(JwtAuthGuard, OrgRoleGuard)
|
|
18
|
+
export class RealtimeController {
|
|
19
|
+
constructor(
|
|
20
|
+
private triggerService: TriggerService,
|
|
21
|
+
private drizzle: DrizzleService,
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
private async getProject(orgSlug: string, projectSlug: string) {
|
|
25
|
+
const [row] = await this.drizzle.db
|
|
26
|
+
.select({ id: projects.id, dbSchema: projects.dbSchema })
|
|
27
|
+
.from(projects)
|
|
28
|
+
.innerJoin(organizations, eq(projects.orgId, organizations.id))
|
|
29
|
+
.where(
|
|
30
|
+
and(eq(organizations.slug, orgSlug), eq(projects.slug, projectSlug)),
|
|
31
|
+
)
|
|
32
|
+
.limit(1);
|
|
33
|
+
|
|
34
|
+
if (!row) throw new NotFoundException('Project not found');
|
|
35
|
+
return row;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@Post(':tableName/enable')
|
|
39
|
+
async enableRealtime(
|
|
40
|
+
@Param('slug') slug: string,
|
|
41
|
+
@Param('projectSlug') projectSlug: string,
|
|
42
|
+
@Param('tableName') tableName: string,
|
|
43
|
+
) {
|
|
44
|
+
const project = await this.getProject(slug, projectSlug);
|
|
45
|
+
await this.triggerService.enableRealtime(
|
|
46
|
+
project.dbSchema,
|
|
47
|
+
project.id,
|
|
48
|
+
tableName,
|
|
49
|
+
);
|
|
50
|
+
return { message: `Realtime enabled for ${tableName}` };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@Delete(':tableName/disable')
|
|
54
|
+
async disableRealtime(
|
|
55
|
+
@Param('slug') slug: string,
|
|
56
|
+
@Param('projectSlug') projectSlug: string,
|
|
57
|
+
@Param('tableName') tableName: string,
|
|
58
|
+
) {
|
|
59
|
+
const project = await this.getProject(slug, projectSlug);
|
|
60
|
+
await this.triggerService.disableRealtime(project.dbSchema, tableName);
|
|
61
|
+
return { message: `Realtime disabled for ${tableName}` };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConnectedSocket,
|
|
3
|
+
MessageBody,
|
|
4
|
+
OnGatewayConnection,
|
|
5
|
+
OnGatewayDisconnect,
|
|
6
|
+
SubscribeMessage,
|
|
7
|
+
WebSocketGateway,
|
|
8
|
+
WebSocketServer,
|
|
9
|
+
} from '@nestjs/websockets';
|
|
10
|
+
import { Server, Socket } from 'socket.io';
|
|
11
|
+
import { JwtService } from '@nestjs/jwt';
|
|
12
|
+
import { ConfigService } from '@nestjs/config';
|
|
13
|
+
import { RealtimeService } from './realtime.service';
|
|
14
|
+
import { REALTIME_EVENTS, PROJECT_KEY_ROLES } from '@apiDatabase/constants';
|
|
15
|
+
import type { RealtimeEvent } from '@apiDatabase/types';
|
|
16
|
+
import type { ProjectKeyPayload } from '../project-api/project-key.guard';
|
|
17
|
+
import type { RealtimeSocket } from './realtime-socket.type';
|
|
18
|
+
|
|
19
|
+
type SocketCallbackEntry = {
|
|
20
|
+
projectId: string;
|
|
21
|
+
tableName: string;
|
|
22
|
+
callback: (event: RealtimeEvent) => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
@WebSocketGateway({
|
|
26
|
+
namespace: '/realtime',
|
|
27
|
+
cors: {
|
|
28
|
+
origin: process.env.WEB_URL ?? 'http://localhost:3001',
|
|
29
|
+
credentials: true,
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
export class RealtimeGateway
|
|
33
|
+
implements OnGatewayConnection, OnGatewayDisconnect
|
|
34
|
+
{
|
|
35
|
+
@WebSocketServer()
|
|
36
|
+
server!: Server;
|
|
37
|
+
|
|
38
|
+
private socketCallbacks = new Map<string, SocketCallbackEntry[]>();
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
private realtimeService: RealtimeService,
|
|
42
|
+
private jwtService: JwtService,
|
|
43
|
+
private configService: ConfigService,
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
async handleConnection(client: RealtimeSocket) {
|
|
47
|
+
const token = client.handshake.auth['token'] as string | undefined;
|
|
48
|
+
|
|
49
|
+
if (!token) {
|
|
50
|
+
client.emit(REALTIME_EVENTS.ERROR, 'Missing API key');
|
|
51
|
+
client.disconnect();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const payload = this.jwtService.verify<ProjectKeyPayload>(token, {
|
|
57
|
+
secret: this.configService.get<string>('PROJECT_JWT_SECRET'),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (
|
|
61
|
+
payload.role !== PROJECT_KEY_ROLES.ANON &&
|
|
62
|
+
payload.role !== PROJECT_KEY_ROLES.SERVICE_ROLE
|
|
63
|
+
) {
|
|
64
|
+
throw new Error('Invalid key role');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
client.data.projectId = payload.projectId;
|
|
68
|
+
client.data.role = payload.role;
|
|
69
|
+
await client.join(`project:${payload.projectId}`);
|
|
70
|
+
} catch {
|
|
71
|
+
client.emit(REALTIME_EVENTS.ERROR, 'Invalid API key');
|
|
72
|
+
client.disconnect();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
handleDisconnect(client: Socket) {
|
|
77
|
+
const callbacks = this.socketCallbacks.get(client.id) ?? [];
|
|
78
|
+
for (const { projectId, tableName, callback } of callbacks) {
|
|
79
|
+
this.realtimeService.unsubscribe(projectId, tableName, callback);
|
|
80
|
+
}
|
|
81
|
+
this.socketCallbacks.delete(client.id);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@SubscribeMessage(REALTIME_EVENTS.SUBSCRIBE)
|
|
85
|
+
async handleSubscribe(
|
|
86
|
+
@ConnectedSocket() client: RealtimeSocket,
|
|
87
|
+
@MessageBody() tableName: string,
|
|
88
|
+
) {
|
|
89
|
+
const projectId = client.data.projectId;
|
|
90
|
+
if (!projectId || !tableName?.trim()) return;
|
|
91
|
+
|
|
92
|
+
const normalizedTable = tableName.trim();
|
|
93
|
+
const room = `project:${projectId}:table:${normalizedTable}`;
|
|
94
|
+
|
|
95
|
+
const existing = this.socketCallbacks.get(client.id) ?? [];
|
|
96
|
+
if (
|
|
97
|
+
existing.some(
|
|
98
|
+
(entry) =>
|
|
99
|
+
entry.projectId === projectId && entry.tableName === normalizedTable,
|
|
100
|
+
)
|
|
101
|
+
) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await client.join(room);
|
|
106
|
+
|
|
107
|
+
const callback = (event: RealtimeEvent) => {
|
|
108
|
+
this.server.to(room).emit(REALTIME_EVENTS.EVENT, event);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
await this.realtimeService.subscribe(projectId, normalizedTable, callback);
|
|
112
|
+
|
|
113
|
+
existing.push({
|
|
114
|
+
projectId,
|
|
115
|
+
tableName: normalizedTable,
|
|
116
|
+
callback,
|
|
117
|
+
});
|
|
118
|
+
this.socketCallbacks.set(client.id, existing);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@SubscribeMessage(REALTIME_EVENTS.UNSUBSCRIBE)
|
|
122
|
+
async handleUnsubscribe(
|
|
123
|
+
@ConnectedSocket() client: RealtimeSocket,
|
|
124
|
+
@MessageBody() tableName: string,
|
|
125
|
+
) {
|
|
126
|
+
const projectId = client.data.projectId;
|
|
127
|
+
if (!projectId || !tableName?.trim()) return;
|
|
128
|
+
|
|
129
|
+
const normalizedTable = tableName.trim();
|
|
130
|
+
const room = `project:${projectId}:table:${normalizedTable}`;
|
|
131
|
+
await client.leave(room);
|
|
132
|
+
|
|
133
|
+
const callbacks = this.socketCallbacks.get(client.id) ?? [];
|
|
134
|
+
const entry = callbacks.find(
|
|
135
|
+
(c) => c.projectId === projectId && c.tableName === normalizedTable,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (entry) {
|
|
139
|
+
this.realtimeService.unsubscribe(
|
|
140
|
+
projectId,
|
|
141
|
+
normalizedTable,
|
|
142
|
+
entry.callback,
|
|
143
|
+
);
|
|
144
|
+
this.socketCallbacks.set(
|
|
145
|
+
client.id,
|
|
146
|
+
callbacks.filter((c) => c !== entry),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { RealtimeGateway } from './realtime.gateway';
|
|
3
|
+
import { RealtimeService } from './realtime.service';
|
|
4
|
+
import { RealtimeController } from './realtime.controller';
|
|
5
|
+
import { TriggerService } from './trigger.service';
|
|
6
|
+
import { AuthModule } from '../auth/auth.module';
|
|
7
|
+
import { OrgRoleGuard } from '../auth/guards/org-role.guards';
|
|
8
|
+
|
|
9
|
+
@Module({
|
|
10
|
+
imports: [AuthModule],
|
|
11
|
+
providers: [RealtimeGateway, RealtimeService, TriggerService, OrgRoleGuard],
|
|
12
|
+
controllers: [RealtimeController],
|
|
13
|
+
})
|
|
14
|
+
export class RealtimeModule {}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
|
2
|
+
import { ConfigService } from '@nestjs/config';
|
|
3
|
+
import { Client, neonConfig } from '@neondatabase/serverless';
|
|
4
|
+
import { TriggerService } from './trigger.service';
|
|
5
|
+
import type { RealtimeEvent } from '@apiDatabase/types';
|
|
6
|
+
|
|
7
|
+
neonConfig.webSocketConstructor = WebSocket;
|
|
8
|
+
|
|
9
|
+
type NotifyCallback = (event: RealtimeEvent) => void;
|
|
10
|
+
|
|
11
|
+
@Injectable()
|
|
12
|
+
export class RealtimeService implements OnModuleDestroy {
|
|
13
|
+
private listeners = new Map<
|
|
14
|
+
string,
|
|
15
|
+
{
|
|
16
|
+
client: Client;
|
|
17
|
+
callbacks: Set<NotifyCallback>;
|
|
18
|
+
}
|
|
19
|
+
>();
|
|
20
|
+
|
|
21
|
+
constructor(private configService: ConfigService) {}
|
|
22
|
+
|
|
23
|
+
// Neon LISTEN/NOTIFY requires a direct (non-pooler) connection
|
|
24
|
+
private getListenConnectionString(): string {
|
|
25
|
+
const realtime = this.configService.get<string>('REALTIME_DATABASE_URL');
|
|
26
|
+
const database = this.configService.get<string>('DATABASE_URL');
|
|
27
|
+
const url = realtime ?? database;
|
|
28
|
+
if (!url) {
|
|
29
|
+
throw new Error('DATABASE_URL is not configured');
|
|
30
|
+
}
|
|
31
|
+
let clean = url.replace('-pooler', '').trim();
|
|
32
|
+
clean = clean.replace(/([?&])(sslmode|channel_binding)=[^&]*/g, '$1');
|
|
33
|
+
clean = clean.replace(/[?&]$/, '').replace(/\?&/, '?');
|
|
34
|
+
return clean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async subscribe(
|
|
38
|
+
projectId: string,
|
|
39
|
+
tableName: string,
|
|
40
|
+
callback: NotifyCallback,
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const channel = TriggerService.channelName(projectId, tableName);
|
|
43
|
+
|
|
44
|
+
const existing = this.listeners.get(channel);
|
|
45
|
+
if (existing) {
|
|
46
|
+
existing.callbacks.add(callback);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const client = new Client({
|
|
51
|
+
connectionString: this.getListenConnectionString(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await client.connect();
|
|
55
|
+
|
|
56
|
+
client.on('notification', (msg) => {
|
|
57
|
+
if (!msg.payload) return;
|
|
58
|
+
try {
|
|
59
|
+
const event = JSON.parse(msg.payload) as RealtimeEvent;
|
|
60
|
+
const entry = this.listeners.get(channel);
|
|
61
|
+
entry?.callbacks.forEach((cb) => cb(event));
|
|
62
|
+
} catch {
|
|
63
|
+
// malformed payload — ignore
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// channel names are lowercase identifiers — no double quotes needed
|
|
68
|
+
await client.query(`LISTEN ${channel}`);
|
|
69
|
+
|
|
70
|
+
this.listeners.set(channel, {
|
|
71
|
+
client,
|
|
72
|
+
callbacks: new Set([callback]),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
unsubscribe(
|
|
77
|
+
projectId: string,
|
|
78
|
+
tableName: string,
|
|
79
|
+
callback: NotifyCallback,
|
|
80
|
+
): void {
|
|
81
|
+
const channel = TriggerService.channelName(projectId, tableName);
|
|
82
|
+
const entry = this.listeners.get(channel);
|
|
83
|
+
if (!entry) return;
|
|
84
|
+
|
|
85
|
+
entry.callbacks.delete(callback);
|
|
86
|
+
|
|
87
|
+
if (entry.callbacks.size === 0) {
|
|
88
|
+
void entry.client.query(`UNLISTEN ${channel}`).finally(() => {
|
|
89
|
+
void entry.client.end();
|
|
90
|
+
});
|
|
91
|
+
this.listeners.delete(channel);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async onModuleDestroy() {
|
|
96
|
+
await Promise.all(
|
|
97
|
+
[...this.listeners.values()].map(({ client }) => client.end()),
|
|
98
|
+
);
|
|
99
|
+
this.listeners.clear();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { BadRequestException, Injectable } from '@nestjs/common';
|
|
2
|
+
import { DrizzleService } from '../db/drizzle.service';
|
|
3
|
+
|
|
4
|
+
@Injectable()
|
|
5
|
+
export class TriggerService {
|
|
6
|
+
constructor(private drizzle: DrizzleService) {}
|
|
7
|
+
|
|
8
|
+
private assertSafeIdentifier(name: string, label: string): void {
|
|
9
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
10
|
+
throw new BadRequestException(`Invalid ${label}: ${name}`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
static channelName(projectId: string, tableName: string): string {
|
|
15
|
+
return `project_${projectId.replace(/-/g, '_')}_${tableName}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async enableRealtime(
|
|
19
|
+
dbSchema: string,
|
|
20
|
+
projectId: string,
|
|
21
|
+
tableName: string,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
this.assertSafeIdentifier(dbSchema, 'schema name');
|
|
24
|
+
this.assertSafeIdentifier(tableName, 'table name');
|
|
25
|
+
|
|
26
|
+
const channel = TriggerService.channelName(projectId, tableName);
|
|
27
|
+
const fnName = `${tableName}_notify`;
|
|
28
|
+
|
|
29
|
+
await this.drizzle.db.execute(`
|
|
30
|
+
CREATE OR REPLACE FUNCTION "${dbSchema}"."${fnName}"()
|
|
31
|
+
RETURNS TRIGGER AS $$
|
|
32
|
+
DECLARE
|
|
33
|
+
payload JSON;
|
|
34
|
+
BEGIN
|
|
35
|
+
IF TG_OP = 'DELETE' THEN
|
|
36
|
+
payload = json_build_object(
|
|
37
|
+
'type', TG_OP,
|
|
38
|
+
'table', TG_TABLE_NAME,
|
|
39
|
+
'record', row_to_json(OLD),
|
|
40
|
+
'oldRecord', row_to_json(OLD),
|
|
41
|
+
'projectId', '${projectId}',
|
|
42
|
+
'timestamp', now()::text
|
|
43
|
+
);
|
|
44
|
+
PERFORM pg_notify('${channel}', payload::text);
|
|
45
|
+
RETURN OLD;
|
|
46
|
+
ELSIF TG_OP = 'UPDATE' THEN
|
|
47
|
+
payload = json_build_object(
|
|
48
|
+
'type', TG_OP,
|
|
49
|
+
'table', TG_TABLE_NAME,
|
|
50
|
+
'record', row_to_json(NEW),
|
|
51
|
+
'oldRecord', row_to_json(OLD),
|
|
52
|
+
'projectId', '${projectId}',
|
|
53
|
+
'timestamp', now()::text
|
|
54
|
+
);
|
|
55
|
+
PERFORM pg_notify('${channel}', payload::text);
|
|
56
|
+
RETURN NEW;
|
|
57
|
+
ELSE
|
|
58
|
+
payload = json_build_object(
|
|
59
|
+
'type', TG_OP,
|
|
60
|
+
'table', TG_TABLE_NAME,
|
|
61
|
+
'record', row_to_json(NEW),
|
|
62
|
+
'projectId', '${projectId}',
|
|
63
|
+
'timestamp', now()::text
|
|
64
|
+
);
|
|
65
|
+
PERFORM pg_notify('${channel}', payload::text);
|
|
66
|
+
RETURN NEW;
|
|
67
|
+
END IF;
|
|
68
|
+
END;
|
|
69
|
+
$$ LANGUAGE plpgsql;
|
|
70
|
+
`);
|
|
71
|
+
|
|
72
|
+
await this.drizzle.db.execute(`
|
|
73
|
+
DROP TRIGGER IF EXISTS "${tableName}_realtime_trigger"
|
|
74
|
+
ON "${dbSchema}"."${tableName}";
|
|
75
|
+
`);
|
|
76
|
+
|
|
77
|
+
await this.drizzle.db.execute(`
|
|
78
|
+
CREATE TRIGGER "${tableName}_realtime_trigger"
|
|
79
|
+
AFTER INSERT OR UPDATE OR DELETE
|
|
80
|
+
ON "${dbSchema}"."${tableName}"
|
|
81
|
+
FOR EACH ROW
|
|
82
|
+
EXECUTE FUNCTION "${dbSchema}"."${fnName}"();
|
|
83
|
+
`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async disableRealtime(dbSchema: string, tableName: string): Promise<void> {
|
|
87
|
+
this.assertSafeIdentifier(dbSchema, 'schema name');
|
|
88
|
+
this.assertSafeIdentifier(tableName, 'table name');
|
|
89
|
+
|
|
90
|
+
await this.drizzle.db.execute(`
|
|
91
|
+
DROP TRIGGER IF EXISTS "${tableName}_realtime_trigger"
|
|
92
|
+
ON "${dbSchema}"."${tableName}";
|
|
93
|
+
`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
|
2
|
+
import { SqlEditorService } from './sql-editor.service';
|
|
3
|
+
import { ExecuteQueryDto } from './dto/execute-query.dto';
|
|
4
|
+
import { JwtAuthGuard } from '../auth/guards/jwt-auth-guard';
|
|
5
|
+
import { OrgRoleGuard } from '../auth/guards/org-role.guards';
|
|
6
|
+
|
|
7
|
+
@Controller('orgs/:slug/projects/:projectSlug/sql')
|
|
8
|
+
@UseGuards(JwtAuthGuard, OrgRoleGuard)
|
|
9
|
+
export class SqlEditorController {
|
|
10
|
+
constructor(private sqlEditorService: SqlEditorService) {}
|
|
11
|
+
|
|
12
|
+
@Post()
|
|
13
|
+
executeQuery(
|
|
14
|
+
@Param('slug') slug: string,
|
|
15
|
+
@Param('projectSlug') projectSlug: string,
|
|
16
|
+
@Body() dto: ExecuteQueryDto,
|
|
17
|
+
) {
|
|
18
|
+
return this.sqlEditorService.executeQuery(slug, projectSlug, dto.sql);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Get('history')
|
|
22
|
+
getHistory(
|
|
23
|
+
@Param('slug') slug: string,
|
|
24
|
+
@Param('projectSlug') projectSlug: string,
|
|
25
|
+
) {
|
|
26
|
+
return this.sqlEditorService.getHistory(slug, projectSlug);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { SqlEditorService } from './sql-editor.service';
|
|
3
|
+
import { SqlEditorController } from './sql-editor.controller';
|
|
4
|
+
import { AuthModule } from '../auth/auth.module';
|
|
5
|
+
import { OrgRoleGuard } from '../auth/guards/org-role.guards';
|
|
6
|
+
|
|
7
|
+
@Module({
|
|
8
|
+
imports: [AuthModule],
|
|
9
|
+
providers: [SqlEditorService, OrgRoleGuard],
|
|
10
|
+
controllers: [SqlEditorController],
|
|
11
|
+
})
|
|
12
|
+
export class SqlEditorModule {}
|