create-svc 0.1.9 → 0.1.10
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 +130 -11
- package/package.json +9 -4
- package/src/cli.test.ts +29 -8
- package/src/cli.ts +103 -70
- package/src/naming.test.ts +4 -2
- package/src/naming.ts +9 -1
- package/src/neon.ts +10 -8
- package/src/post-scaffold.ts +7 -28
- package/src/profiles.ts +28 -0
- package/src/scaffold.test.ts +126 -15
- package/src/scaffold.ts +94 -23
- package/src/vault.test.ts +33 -9
- package/src/vault.ts +4 -3
- package/templates/shared/README.md +135 -24
- package/templates/shared/docker-compose.yml +19 -0
- package/templates/shared/scripts/cloudrun/bootstrap.ts +15 -42
- package/templates/shared/scripts/cloudrun/cleanup.ts +17 -31
- package/templates/shared/scripts/cloudrun/config.ts +14 -19
- package/templates/shared/scripts/cloudrun/deploy.ts +19 -10
- package/templates/shared/scripts/cloudrun/integrations.ts +111 -0
- package/templates/shared/scripts/cloudrun/lib.ts +88 -112
- package/templates/shared/scripts/cloudrun/neon.ts +82 -13
- package/templates/shared/service.yaml +44 -1
- package/templates/variants/bun-connectrpc/Dockerfile +1 -0
- package/templates/variants/bun-connectrpc/Makefile +4 -1
- package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +1078 -0
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +63 -0
- package/templates/variants/bun-connectrpc/package.json +17 -0
- package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +228 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
- package/templates/variants/bun-connectrpc/scripts/migrate.ts +46 -0
- package/templates/variants/bun-connectrpc/src/chat/service.ts +384 -0
- package/templates/variants/bun-connectrpc/src/chat/types.ts +142 -0
- package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +479 -0
- package/templates/variants/bun-connectrpc/src/db/schema.ts +75 -0
- package/templates/variants/bun-connectrpc/src/index.ts +294 -22
- package/templates/variants/bun-connectrpc/src/storage.ts +72 -0
- package/templates/variants/bun-connectrpc/src/webhooks.ts +35 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
- package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +182 -0
- package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
- package/templates/variants/bun-hono/Makefile +4 -1
- package/templates/variants/bun-hono/migrations/0000_init.sql +63 -0
- package/templates/variants/bun-hono/package.json +13 -0
- package/templates/variants/bun-hono/scripts/migrate.ts +46 -0
- package/templates/variants/bun-hono/src/chat/service.ts +384 -0
- package/templates/variants/bun-hono/src/chat/types.ts +142 -0
- package/templates/variants/bun-hono/src/db/client.ts +15 -0
- package/templates/variants/bun-hono/src/db/repository.ts +479 -0
- package/templates/variants/bun-hono/src/db/schema.ts +75 -0
- package/templates/variants/bun-hono/src/index.ts +254 -8
- package/templates/variants/bun-hono/src/storage.ts +72 -0
- package/templates/variants/bun-hono/src/webhooks.ts +35 -0
- package/templates/variants/bun-hono/test/app.test.ts +60 -6
- package/templates/variants/bun-hono/test/list-messages.integration.test.ts +256 -0
- package/templates/variants/bun-hono/tsconfig.json +1 -0
- package/templates/variants/go-chi/Makefile +6 -2
- package/templates/variants/go-chi/buf.gen.yaml +2 -0
- package/templates/variants/go-chi/cmd/migrate/main.go +101 -0
- package/templates/variants/go-chi/cmd/server/main.go +16 -15
- package/templates/variants/go-chi/go.mod +3 -0
- package/templates/variants/go-chi/internal/app/service.go +763 -71
- package/templates/variants/go-chi/internal/config/config.go +22 -7
- package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +298 -0
- package/templates/variants/go-chi/internal/httpapi/routes.go +245 -43
- package/templates/variants/go-chi/migrations/0000_init.sql +63 -0
- package/templates/variants/go-chi/protos/chat/v1/chat.proto +219 -0
- package/templates/variants/go-chi/test/go.test.ts +4 -1
- package/templates/variants/go-connectrpc/Makefile +6 -2
- package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
- package/templates/variants/go-connectrpc/cmd/migrate/main.go +101 -0
- package/templates/variants/go-connectrpc/cmd/server/main.go +35 -11
- package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +2512 -0
- package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +571 -0
- package/templates/variants/go-connectrpc/go.mod +4 -0
- package/templates/variants/go-connectrpc/internal/app/service.go +763 -71
- package/templates/variants/go-connectrpc/internal/config/config.go +22 -7
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +254 -42
- package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +216 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +41 -56
- package/templates/variants/go-connectrpc/migrations/0000_init.sql +63 -0
- package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +232 -0
- package/templates/shared/.env.example +0 -10
- package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
- package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
- package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +0 -58
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
create table if not exists users (
|
|
2
|
+
id text primary key,
|
|
3
|
+
username text not null unique,
|
|
4
|
+
display_name text,
|
|
5
|
+
created_at timestamptz not null default now(),
|
|
6
|
+
updated_at timestamptz not null default now()
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
create table if not exists conversations (
|
|
10
|
+
id text primary key,
|
|
11
|
+
title text,
|
|
12
|
+
created_by_user_id text not null references users(id),
|
|
13
|
+
deleted_at timestamptz,
|
|
14
|
+
created_at timestamptz not null default now(),
|
|
15
|
+
updated_at timestamptz not null default now()
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
create table if not exists conversation_participants (
|
|
19
|
+
conversation_id text not null references conversations(id) on delete cascade,
|
|
20
|
+
user_id text not null references users(id) on delete cascade,
|
|
21
|
+
joined_at timestamptz not null default now(),
|
|
22
|
+
primary key (conversation_id, user_id)
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
create table if not exists messages (
|
|
26
|
+
id text primary key,
|
|
27
|
+
conversation_id text not null references conversations(id) on delete cascade,
|
|
28
|
+
user_id text not null references users(id),
|
|
29
|
+
body text not null,
|
|
30
|
+
edited_at timestamptz,
|
|
31
|
+
deleted_at timestamptz,
|
|
32
|
+
created_at timestamptz not null default now(),
|
|
33
|
+
updated_at timestamptz not null default now()
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
create table if not exists attachments (
|
|
37
|
+
id text primary key,
|
|
38
|
+
conversation_id text not null references conversations(id) on delete cascade,
|
|
39
|
+
message_id text references messages(id),
|
|
40
|
+
uploaded_by_user_id text not null references users(id),
|
|
41
|
+
storage_bucket text not null,
|
|
42
|
+
storage_key text not null,
|
|
43
|
+
content_type text not null,
|
|
44
|
+
byte_size integer not null,
|
|
45
|
+
filename text not null,
|
|
46
|
+
status text not null,
|
|
47
|
+
deleted_at timestamptz,
|
|
48
|
+
created_at timestamptz not null default now(),
|
|
49
|
+
updated_at timestamptz not null default now()
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
create table if not exists webhook_events (
|
|
53
|
+
id text primary key,
|
|
54
|
+
provider text not null,
|
|
55
|
+
external_event_id text not null,
|
|
56
|
+
event_type text not null,
|
|
57
|
+
signature_valid text not null,
|
|
58
|
+
status text not null,
|
|
59
|
+
payload_json text not null,
|
|
60
|
+
received_at timestamptz not null default now(),
|
|
61
|
+
processed_at timestamptz,
|
|
62
|
+
unique(provider, external_event_id)
|
|
63
|
+
);
|
|
@@ -5,13 +5,26 @@
|
|
|
5
5
|
"bin": {
|
|
6
6
|
"svc-cloudrun": "./scripts/cloudrun/cli.ts"
|
|
7
7
|
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "bun run ./src/index.ts",
|
|
10
|
+
"migrate": "bun run ./scripts/migrate.ts",
|
|
11
|
+
"gen": "bun run ./scripts/codegen.ts",
|
|
12
|
+
"lint": "bunx tsc --noEmit",
|
|
13
|
+
"test": "bun test",
|
|
14
|
+
"bootstrap": "bun run ./scripts/cloudrun/cli.ts bootstrap",
|
|
15
|
+
"deploy": "bun run ./scripts/cloudrun/cli.ts deploy",
|
|
16
|
+
"cleanup": "bun run ./scripts/cloudrun/cli.ts cleanup"
|
|
17
|
+
},
|
|
8
18
|
"dependencies": {
|
|
19
|
+
"@google-cloud/storage": "^7.17.2",
|
|
9
20
|
"@clack/prompts": "^1.2.0",
|
|
21
|
+
"drizzle-orm": "^0.44.5",
|
|
10
22
|
"@neondatabase/api-client": "^2.7.1",
|
|
11
23
|
"hono": "^4.10.1"
|
|
12
24
|
},
|
|
13
25
|
"devDependencies": {
|
|
14
26
|
"@types/bun": "latest",
|
|
27
|
+
"drizzle-kit": "^0.31.4",
|
|
15
28
|
"typescript": "^5.9.3"
|
|
16
29
|
}
|
|
17
30
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { SQL } from "bun";
|
|
2
|
+
|
|
3
|
+
const databaseUrl = Bun.env.DATABASE_URL?.trim();
|
|
4
|
+
if (!databaseUrl) {
|
|
5
|
+
throw new Error("DATABASE_URL is required");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const client = new SQL(databaseUrl);
|
|
9
|
+
await waitForDatabase(client);
|
|
10
|
+
const migrationId = "0000_init_chat";
|
|
11
|
+
const migrationSql = await Bun.file(new URL("../migrations/0000_init.sql", import.meta.url)).text();
|
|
12
|
+
|
|
13
|
+
await client.unsafe(`create table if not exists schema_migrations (
|
|
14
|
+
id text primary key,
|
|
15
|
+
applied_at timestamptz not null default now()
|
|
16
|
+
)`);
|
|
17
|
+
|
|
18
|
+
const existing = await client`select id from schema_migrations where id = ${migrationId} limit 1`;
|
|
19
|
+
if (existing.length === 0) {
|
|
20
|
+
await client.unsafe(migrationSql);
|
|
21
|
+
await client`insert into schema_migrations (id) values (${migrationId})`;
|
|
22
|
+
console.log(`Applied migration ${migrationId}`);
|
|
23
|
+
} else {
|
|
24
|
+
console.log(`Migration ${migrationId} already applied`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function waitForDatabase(client: SQL, timeoutMs = 30_000) {
|
|
28
|
+
const start = Date.now();
|
|
29
|
+
let lastError: unknown;
|
|
30
|
+
|
|
31
|
+
while (Date.now() - start < timeoutMs) {
|
|
32
|
+
try {
|
|
33
|
+
await client.unsafe("select 1");
|
|
34
|
+
return;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
lastError = error;
|
|
37
|
+
await Bun.sleep(1_000);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
throw new Error(`Timed out waiting for DATABASE_URL to accept connections: ${formatError(lastError)}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatError(error: unknown) {
|
|
45
|
+
return error instanceof Error ? error.message : String(error ?? "unknown error");
|
|
46
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AttachmentObjectMetadata,
|
|
3
|
+
ChatAttachment,
|
|
4
|
+
ChatConversation,
|
|
5
|
+
ChatMessage,
|
|
6
|
+
ChatUser,
|
|
7
|
+
CreateAttachmentUploadInput,
|
|
8
|
+
CreateAttachmentUploadResult,
|
|
9
|
+
CreateConversationInput,
|
|
10
|
+
ListMessagesInput,
|
|
11
|
+
ListMessagesResult,
|
|
12
|
+
CreateMessageInput,
|
|
13
|
+
CreateUserInput,
|
|
14
|
+
FinalizeAttachmentInput,
|
|
15
|
+
UpdateConversationInput,
|
|
16
|
+
UpdateMessageInput,
|
|
17
|
+
WebhookProcessResult,
|
|
18
|
+
} from "./types";
|
|
19
|
+
import { createDb } from "../db/client";
|
|
20
|
+
import { ChatRepository } from "../db/repository";
|
|
21
|
+
import { createAttachmentStorage, type AttachmentStorage } from "../storage";
|
|
22
|
+
import { createWebhookAdapter, type WebhookAdapter } from "../webhooks";
|
|
23
|
+
|
|
24
|
+
export class AppError extends Error {
|
|
25
|
+
constructor(
|
|
26
|
+
readonly status: number,
|
|
27
|
+
readonly code: string,
|
|
28
|
+
message: string
|
|
29
|
+
) {
|
|
30
|
+
super(message);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ChatService = {
|
|
35
|
+
createUser(input: CreateUserInput): Promise<ChatUser>;
|
|
36
|
+
getUser(userId: string): Promise<ChatUser>;
|
|
37
|
+
getUserByUsername(username: string): Promise<ChatUser>;
|
|
38
|
+
createConversation(input: CreateConversationInput): Promise<ChatConversation>;
|
|
39
|
+
getConversation(conversationId: string): Promise<ChatConversation>;
|
|
40
|
+
updateConversation(conversationId: string, input: UpdateConversationInput): Promise<ChatConversation>;
|
|
41
|
+
deleteConversation(conversationId: string): Promise<void>;
|
|
42
|
+
addParticipant(conversationId: string, userId: string): Promise<ChatConversation>;
|
|
43
|
+
removeParticipant(conversationId: string, userId: string): Promise<void>;
|
|
44
|
+
listMessages(conversationId: string, input?: ListMessagesInput): Promise<ListMessagesResult>;
|
|
45
|
+
createMessage(conversationId: string, input: CreateMessageInput): Promise<ChatMessage>;
|
|
46
|
+
updateMessage(conversationId: string, messageId: string, input: UpdateMessageInput): Promise<ChatMessage>;
|
|
47
|
+
deleteMessage(conversationId: string, messageId: string): Promise<void>;
|
|
48
|
+
createAttachmentUpload(input: CreateAttachmentUploadInput): Promise<CreateAttachmentUploadResult>;
|
|
49
|
+
finalizeAttachment(attachmentId: string, input: FinalizeAttachmentInput): Promise<ChatAttachment>;
|
|
50
|
+
getAttachment(attachmentId: string): Promise<ChatAttachment>;
|
|
51
|
+
deleteAttachment(attachmentId: string): Promise<void>;
|
|
52
|
+
processWebhook(provider: string, headers: Headers, rawBody: string): Promise<WebhookProcessResult>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export class DefaultChatService implements ChatService {
|
|
56
|
+
constructor(
|
|
57
|
+
private readonly repository: ChatRepository,
|
|
58
|
+
private readonly storage: AttachmentStorage,
|
|
59
|
+
private readonly webhookAdapter: WebhookAdapter
|
|
60
|
+
) {}
|
|
61
|
+
|
|
62
|
+
async createUser(input: CreateUserInput) {
|
|
63
|
+
const username = input.username.trim().toLowerCase();
|
|
64
|
+
if (!username) {
|
|
65
|
+
throw new AppError(400, "invalid_username", "username is required");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const existing = await this.repository.getUserByUsername(username);
|
|
69
|
+
if (existing) {
|
|
70
|
+
throw new AppError(409, "username_taken", `username ${username} already exists`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return this.repository.createUser({
|
|
74
|
+
id: crypto.randomUUID(),
|
|
75
|
+
username,
|
|
76
|
+
displayName: normalizeNullableText(input.displayName),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async getUser(userId: string) {
|
|
81
|
+
return this.requireUser(userId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async getUserByUsername(username: string) {
|
|
85
|
+
const normalized = username.trim().toLowerCase();
|
|
86
|
+
if (!normalized) {
|
|
87
|
+
throw new AppError(400, "invalid_username", "username is required");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const user = await this.repository.getUserByUsername(normalized);
|
|
91
|
+
if (!user) {
|
|
92
|
+
throw new AppError(404, "user_not_found", `user ${normalized} not found`);
|
|
93
|
+
}
|
|
94
|
+
return user;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async createConversation(input: CreateConversationInput) {
|
|
98
|
+
const createdBy = await this.requireUser(input.createdByUserId);
|
|
99
|
+
const participantUserIds = new Set([createdBy.id, ...(input.participantUserIds ?? []).map((value) => value.trim()).filter(Boolean)]);
|
|
100
|
+
await this.requireUsers(Array.from(participantUserIds));
|
|
101
|
+
|
|
102
|
+
return this.repository.createConversation({
|
|
103
|
+
id: crypto.randomUUID(),
|
|
104
|
+
createdByUserId: createdBy.id,
|
|
105
|
+
title: normalizeNullableText(input.title),
|
|
106
|
+
participantUserIds: Array.from(participantUserIds),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async getConversation(conversationId: string) {
|
|
111
|
+
return this.requireConversation(conversationId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async updateConversation(conversationId: string, input: UpdateConversationInput) {
|
|
115
|
+
await this.requireConversation(conversationId);
|
|
116
|
+
return this.repository.updateConversation(conversationId, normalizeNullableText(input.title));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async deleteConversation(conversationId: string) {
|
|
120
|
+
await this.requireConversation(conversationId);
|
|
121
|
+
await this.repository.softDeleteConversation(conversationId);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async addParticipant(conversationId: string, userId: string) {
|
|
125
|
+
await this.requireConversation(conversationId);
|
|
126
|
+
const user = await this.requireUser(userId);
|
|
127
|
+
await this.repository.addParticipant(conversationId, user.id);
|
|
128
|
+
return this.requireConversation(conversationId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async removeParticipant(conversationId: string, userId: string) {
|
|
132
|
+
const conversation = await this.requireConversation(conversationId);
|
|
133
|
+
const isParticipant = conversation.participants.some((participant) => participant.id === userId);
|
|
134
|
+
if (!isParticipant) {
|
|
135
|
+
throw new AppError(404, "participant_not_found", `user ${userId} is not a participant`);
|
|
136
|
+
}
|
|
137
|
+
await this.repository.removeParticipant(conversationId, userId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async listMessages(conversationId: string, input: ListMessagesInput = {}) {
|
|
141
|
+
await this.requireConversation(conversationId);
|
|
142
|
+
const limit = normalizeMessagePageSize(input.limit);
|
|
143
|
+
const cursor = parseMessageCursor(input.cursor);
|
|
144
|
+
const result = await this.repository.listMessages(conversationId, { limit, cursor });
|
|
145
|
+
return {
|
|
146
|
+
messages: result.messages,
|
|
147
|
+
nextCursor: result.hasMore ? encodeMessageCursor(result.messages[result.messages.length - 1]!) : undefined,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async createMessage(conversationId: string, input: CreateMessageInput) {
|
|
152
|
+
const conversation = await this.requireConversation(conversationId);
|
|
153
|
+
const user = await this.requireUser(input.userId);
|
|
154
|
+
if (!conversation.participants.some((participant) => participant.id === user.id)) {
|
|
155
|
+
throw new AppError(409, "not_a_participant", `user ${user.id} is not a participant`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const body = input.body.trim();
|
|
159
|
+
if (!body) {
|
|
160
|
+
throw new AppError(400, "invalid_body", "message body is required");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return this.repository.createMessage({
|
|
164
|
+
id: crypto.randomUUID(),
|
|
165
|
+
conversationId,
|
|
166
|
+
userId: user.id,
|
|
167
|
+
body,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async updateMessage(conversationId: string, messageId: string, input: UpdateMessageInput) {
|
|
172
|
+
await this.requireConversation(conversationId);
|
|
173
|
+
const existing = await this.repository.getMessageById(messageId);
|
|
174
|
+
if (!existing || existing.conversationId !== conversationId) {
|
|
175
|
+
throw new AppError(404, "message_not_found", `message ${messageId} not found`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const body = input.body.trim();
|
|
179
|
+
if (!body) {
|
|
180
|
+
throw new AppError(400, "invalid_body", "message body is required");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return this.repository.updateMessage(messageId, body);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async deleteMessage(conversationId: string, messageId: string) {
|
|
187
|
+
await this.requireConversation(conversationId);
|
|
188
|
+
const existing = await this.repository.getMessageById(messageId);
|
|
189
|
+
if (!existing || existing.conversationId !== conversationId) {
|
|
190
|
+
throw new AppError(404, "message_not_found", `message ${messageId} not found`);
|
|
191
|
+
}
|
|
192
|
+
await this.repository.softDeleteMessage(messageId);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async createAttachmentUpload(input: CreateAttachmentUploadInput) {
|
|
196
|
+
if (!input.contentType.startsWith("image/")) {
|
|
197
|
+
throw new AppError(400, "invalid_content_type", "only image uploads are supported");
|
|
198
|
+
}
|
|
199
|
+
await this.requireConversation(input.conversationId);
|
|
200
|
+
const user = await this.requireUser(input.uploadedByUserId);
|
|
201
|
+
if (input.byteSize <= 0) {
|
|
202
|
+
throw new AppError(400, "invalid_byte_size", "byte_size must be positive");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const attachmentId = crypto.randomUUID();
|
|
206
|
+
const uploadTarget = await this.storage.createSignedUpload({
|
|
207
|
+
attachmentId,
|
|
208
|
+
conversationId: input.conversationId,
|
|
209
|
+
filename: input.filename,
|
|
210
|
+
contentType: input.contentType,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const attachment = await this.repository.createAttachment({
|
|
214
|
+
id: attachmentId,
|
|
215
|
+
conversationId: input.conversationId,
|
|
216
|
+
uploadedByUserId: user.id,
|
|
217
|
+
storageBucket: uploadTarget.bucket,
|
|
218
|
+
storageKey: uploadTarget.key,
|
|
219
|
+
contentType: input.contentType,
|
|
220
|
+
byteSize: input.byteSize,
|
|
221
|
+
filename: input.filename,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
attachment: { ...attachment, publicUrl: uploadTarget.publicUrl },
|
|
226
|
+
upload: uploadTarget.upload,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async finalizeAttachment(attachmentId: string, input: FinalizeAttachmentInput) {
|
|
231
|
+
const attachment = await this.requireAttachment(attachmentId);
|
|
232
|
+
let messageId: string | null = null;
|
|
233
|
+
if (input.messageId) {
|
|
234
|
+
const message = await this.repository.getMessageById(input.messageId);
|
|
235
|
+
if (!message || message.conversationId !== attachment.conversationId) {
|
|
236
|
+
throw new AppError(404, "message_not_found", `message ${input.messageId} not found`);
|
|
237
|
+
}
|
|
238
|
+
messageId = message.id;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const metadata = await this.storage.getObjectMetadata({
|
|
242
|
+
bucket: attachment.storageBucket,
|
|
243
|
+
key: attachment.storageKey,
|
|
244
|
+
});
|
|
245
|
+
this.validateAttachmentMetadata(attachment, metadata);
|
|
246
|
+
|
|
247
|
+
const finalized = await this.repository.finalizeAttachment(attachmentId, {
|
|
248
|
+
messageId,
|
|
249
|
+
contentType: metadata.contentType,
|
|
250
|
+
byteSize: metadata.byteSize,
|
|
251
|
+
});
|
|
252
|
+
return { ...finalized, publicUrl: metadata.publicUrl };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async getAttachment(attachmentId: string) {
|
|
256
|
+
return this.requireAttachment(attachmentId);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async deleteAttachment(attachmentId: string) {
|
|
260
|
+
await this.requireAttachment(attachmentId);
|
|
261
|
+
await this.repository.softDeleteAttachment(attachmentId);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async processWebhook(provider: string, headers: Headers, rawBody: string): Promise<WebhookProcessResult> {
|
|
265
|
+
const event = await this.webhookAdapter.normalize(provider, headers, rawBody);
|
|
266
|
+
const existing = await this.repository.getWebhookEvent(event.provider, event.externalEventId);
|
|
267
|
+
if (existing) {
|
|
268
|
+
return { event: existing, duplicate: true };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const created = await this.repository.createWebhookEvent(event);
|
|
272
|
+
const status = event.signatureValid ? "processed" : "failed";
|
|
273
|
+
const updated = await this.repository.markWebhookEventProcessed(created.id, status);
|
|
274
|
+
return { event: updated, duplicate: false };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private async requireUser(userId: string) {
|
|
278
|
+
const user = await this.repository.getUserById(userId);
|
|
279
|
+
if (!user) {
|
|
280
|
+
throw new AppError(404, "user_not_found", `user ${userId} not found`);
|
|
281
|
+
}
|
|
282
|
+
return user;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private async requireUsers(userIds: string[]) {
|
|
286
|
+
const users = await this.repository.listUsersByIds(userIds);
|
|
287
|
+
if (users.length !== userIds.length) {
|
|
288
|
+
throw new AppError(404, "user_not_found", "one or more users do not exist");
|
|
289
|
+
}
|
|
290
|
+
return users;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private async requireConversation(conversationId: string) {
|
|
294
|
+
const conversation = await this.repository.getConversationById(conversationId);
|
|
295
|
+
if (!conversation) {
|
|
296
|
+
throw new AppError(404, "conversation_not_found", `conversation ${conversationId} not found`);
|
|
297
|
+
}
|
|
298
|
+
return conversation;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async requireAttachment(attachmentId: string) {
|
|
302
|
+
const attachment = await this.repository.getAttachmentById(attachmentId);
|
|
303
|
+
if (!attachment) {
|
|
304
|
+
throw new AppError(404, "attachment_not_found", `attachment ${attachmentId} not found`);
|
|
305
|
+
}
|
|
306
|
+
const metadata = await this.storage.getObjectMetadata({
|
|
307
|
+
bucket: attachment.storageBucket,
|
|
308
|
+
key: attachment.storageKey,
|
|
309
|
+
}).catch(() => null);
|
|
310
|
+
return {
|
|
311
|
+
...attachment,
|
|
312
|
+
publicUrl: metadata?.publicUrl ?? attachment.publicUrl,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private validateAttachmentMetadata(attachment: ChatAttachment, metadata: AttachmentObjectMetadata) {
|
|
317
|
+
if (metadata.contentType !== attachment.contentType) {
|
|
318
|
+
throw new AppError(409, "content_type_mismatch", "uploaded object content_type does not match pending attachment");
|
|
319
|
+
}
|
|
320
|
+
if (metadata.byteSize !== attachment.byteSize) {
|
|
321
|
+
throw new AppError(409, "byte_size_mismatch", "uploaded object byte_size does not match pending attachment");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function createDefaultChatService() {
|
|
327
|
+
const db = createDb();
|
|
328
|
+
return new DefaultChatService(new ChatRepository(db), createAttachmentStorage(), createWebhookAdapter());
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function normalizeNullableText(value: string | null | undefined) {
|
|
332
|
+
if (value === undefined) {
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
if (value === null) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
const trimmed = value.trim();
|
|
339
|
+
return trimmed || null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const DEFAULT_MESSAGE_PAGE_SIZE = 50;
|
|
343
|
+
const MAX_MESSAGE_PAGE_SIZE = 100;
|
|
344
|
+
|
|
345
|
+
function normalizeMessagePageSize(limit: number | null | undefined) {
|
|
346
|
+
if (limit == null) {
|
|
347
|
+
return DEFAULT_MESSAGE_PAGE_SIZE;
|
|
348
|
+
}
|
|
349
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
350
|
+
throw new AppError(400, "invalid_limit", "limit must be a positive integer");
|
|
351
|
+
}
|
|
352
|
+
if (limit > MAX_MESSAGE_PAGE_SIZE) {
|
|
353
|
+
throw new AppError(400, "invalid_limit", `limit must be at most ${MAX_MESSAGE_PAGE_SIZE}`);
|
|
354
|
+
}
|
|
355
|
+
return limit;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
type MessageCursor = {
|
|
359
|
+
createdAt: string;
|
|
360
|
+
id: string;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
function parseMessageCursor(cursor: string | null | undefined): MessageCursor | undefined {
|
|
364
|
+
if (!cursor) {
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Partial<MessageCursor>;
|
|
370
|
+
if (typeof value.createdAt !== "string" || typeof value.id !== "string" || !value.createdAt || !value.id) {
|
|
371
|
+
throw new Error("invalid cursor payload");
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
createdAt: value.createdAt,
|
|
375
|
+
id: value.id,
|
|
376
|
+
};
|
|
377
|
+
} catch {
|
|
378
|
+
throw new AppError(400, "invalid_cursor", "cursor is invalid");
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function encodeMessageCursor(message: { createdAt: string; id: string }) {
|
|
383
|
+
return Buffer.from(JSON.stringify({ createdAt: message.createdAt, id: message.id }), "utf8").toString("base64url");
|
|
384
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
export type ChatUser = {
|
|
2
|
+
id: string;
|
|
3
|
+
username: string;
|
|
4
|
+
displayName: string | null;
|
|
5
|
+
createdAt: string;
|
|
6
|
+
updatedAt: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type ChatConversation = {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string | null;
|
|
12
|
+
createdByUserId: string;
|
|
13
|
+
participants: ChatUser[];
|
|
14
|
+
createdAt: string;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ChatMessage = {
|
|
19
|
+
id: string;
|
|
20
|
+
conversationId: string;
|
|
21
|
+
userId: string;
|
|
22
|
+
body: string;
|
|
23
|
+
editedAt: string | null;
|
|
24
|
+
createdAt: string;
|
|
25
|
+
updatedAt: string;
|
|
26
|
+
attachments: ChatMessageAttachment[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ChatMessageAttachment = {
|
|
30
|
+
id: string;
|
|
31
|
+
filename: string;
|
|
32
|
+
contentType: string;
|
|
33
|
+
byteSize: number;
|
|
34
|
+
status: "pending" | "ready" | "deleted";
|
|
35
|
+
publicUrl: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type ChatAttachment = {
|
|
39
|
+
id: string;
|
|
40
|
+
conversationId: string;
|
|
41
|
+
messageId: string | null;
|
|
42
|
+
uploadedByUserId: string;
|
|
43
|
+
storageBucket: string;
|
|
44
|
+
storageKey: string;
|
|
45
|
+
contentType: string;
|
|
46
|
+
byteSize: number;
|
|
47
|
+
filename: string;
|
|
48
|
+
status: "pending" | "ready" | "deleted";
|
|
49
|
+
publicUrl: string;
|
|
50
|
+
createdAt: string;
|
|
51
|
+
updatedAt: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type WebhookEventRecord = {
|
|
55
|
+
id: string;
|
|
56
|
+
provider: string;
|
|
57
|
+
externalEventId: string;
|
|
58
|
+
eventType: string;
|
|
59
|
+
signatureValid: boolean;
|
|
60
|
+
status: "received" | "processed" | "failed";
|
|
61
|
+
payloadJson: string;
|
|
62
|
+
receivedAt: string;
|
|
63
|
+
processedAt: string | null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type CreateUserInput = {
|
|
67
|
+
username: string;
|
|
68
|
+
displayName?: string | null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type CreateConversationInput = {
|
|
72
|
+
createdByUserId: string;
|
|
73
|
+
title?: string | null;
|
|
74
|
+
participantUserIds?: string[];
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type UpdateConversationInput = {
|
|
78
|
+
title?: string | null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type CreateMessageInput = {
|
|
82
|
+
userId: string;
|
|
83
|
+
body: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type ListMessagesInput = {
|
|
87
|
+
cursor?: string | null;
|
|
88
|
+
limit?: number | null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type UpdateMessageInput = {
|
|
92
|
+
body: string;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type CreateAttachmentUploadInput = {
|
|
96
|
+
conversationId: string;
|
|
97
|
+
uploadedByUserId: string;
|
|
98
|
+
filename: string;
|
|
99
|
+
contentType: string;
|
|
100
|
+
byteSize: number;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type FinalizeAttachmentInput = {
|
|
104
|
+
messageId?: string | null;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export type AttachmentUploadTarget = {
|
|
108
|
+
method: "PUT";
|
|
109
|
+
url: string;
|
|
110
|
+
headers: Record<string, string>;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export type CreateAttachmentUploadResult = {
|
|
114
|
+
attachment: ChatAttachment;
|
|
115
|
+
upload: AttachmentUploadTarget;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export type AttachmentObjectMetadata = {
|
|
119
|
+
bucket: string;
|
|
120
|
+
key: string;
|
|
121
|
+
contentType: string;
|
|
122
|
+
byteSize: number;
|
|
123
|
+
publicUrl: string;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export type NormalizedWebhookEvent = {
|
|
127
|
+
provider: string;
|
|
128
|
+
externalEventId: string;
|
|
129
|
+
eventType: string;
|
|
130
|
+
signatureValid: boolean;
|
|
131
|
+
payloadJson: string;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export type WebhookProcessResult = {
|
|
135
|
+
event: WebhookEventRecord;
|
|
136
|
+
duplicate: boolean;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export type ListMessagesResult = {
|
|
140
|
+
messages: ChatMessage[];
|
|
141
|
+
nextCursor?: string;
|
|
142
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { SQL } from "bun";
|
|
2
|
+
import { drizzle } from "drizzle-orm/bun-sql";
|
|
3
|
+
|
|
4
|
+
export function requireDatabaseUrl() {
|
|
5
|
+
const databaseUrl = Bun.env.DATABASE_URL?.trim();
|
|
6
|
+
if (!databaseUrl) {
|
|
7
|
+
throw new Error("DATABASE_URL is required");
|
|
8
|
+
}
|
|
9
|
+
return databaseUrl;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createDb(databaseUrl = requireDatabaseUrl()) {
|
|
13
|
+
const client = new SQL(databaseUrl);
|
|
14
|
+
return drizzle({ client });
|
|
15
|
+
}
|