create-svc 0.1.10 → 0.1.11
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 +46 -43
- package/bin/create-service.mjs +2 -0
- package/package.json +12 -9
- package/src/cli.test.ts +28 -10
- package/src/cli.ts +195 -30
- package/src/git-bootstrap.test.ts +40 -0
- package/src/git-bootstrap.ts +110 -0
- package/src/naming.test.ts +1 -0
- package/src/naming.ts +23 -0
- package/src/post-scaffold.test.ts +19 -0
- package/src/post-scaffold.ts +17 -4
- package/src/profiles.ts +2 -5
- package/src/scaffold.test.ts +231 -40
- package/src/scaffold.ts +84 -29
- package/src/vault.test.ts +61 -1
- package/src/vault.ts +77 -15
- package/templates/shared/.github/workflows/ci.yml +2 -1
- package/templates/shared/.github/workflows/deploy.yml +2 -0
- package/templates/shared/README.md +124 -47
- package/templates/shared/grafana/alerts.yaml +54 -0
- package/templates/shared/grafana/waitlist-dashboard.json +63 -0
- package/templates/shared/scripts/authctl.ts +231 -0
- package/templates/shared/scripts/cloudrun/bootstrap.ts +14 -5
- package/templates/shared/scripts/cloudrun/cleanup.ts +64 -4
- package/templates/shared/scripts/cloudrun/cli.ts +324 -7
- package/templates/shared/scripts/cloudrun/config.ts +11 -4
- package/templates/shared/scripts/cloudrun/deploy.ts +0 -4
- package/templates/shared/scripts/cloudrun/lib.ts +174 -41
- package/templates/shared/scripts/cloudrun/neon.ts +45 -0
- package/templates/shared/scripts/dev.ts +22 -0
- package/templates/shared/scripts/ensure-local-db.ts +3 -0
- package/templates/shared/scripts/local-docker.ts +63 -0
- package/templates/shared/scripts/local-env.ts +27 -0
- package/templates/shared/scripts/seed.ts +73 -0
- package/templates/shared/scripts/wait-for-db.ts +32 -0
- package/templates/shared/service.config.ts +59 -0
- package/templates/shared/service.yaml +24 -44
- package/templates/targets/workers/.github/workflows/ci.yml +19 -0
- package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
- package/templates/targets/workers/Makefile +33 -0
- package/templates/targets/workers/README.md +75 -0
- package/templates/targets/workers/package.json +35 -0
- package/templates/targets/workers/scripts/workers/cli.ts +397 -0
- package/templates/targets/workers/src/auth.ts +178 -0
- package/templates/targets/workers/src/index.ts +198 -0
- package/templates/targets/workers/src/storage.ts +370 -0
- package/templates/targets/workers/test/app.test.ts +108 -0
- package/templates/targets/workers/tsconfig.json +11 -0
- package/templates/targets/workers/wrangler.toml +24 -0
- package/templates/variants/bun-connectrpc/Makefile +14 -8
- package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +12 -55
- package/templates/variants/bun-connectrpc/package.json +12 -5
- package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -1
- package/templates/variants/bun-connectrpc/scripts/migrate.ts +4 -1
- package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +67 -420
- package/templates/variants/bun-connectrpc/src/db/schema.ts +15 -64
- package/templates/variants/bun-connectrpc/src/index.ts +76 -176
- package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
- package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
- package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
- package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
- package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +4 -4
- package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
- package/templates/variants/bun-hono/Makefile +14 -8
- package/templates/variants/bun-hono/migrations/0000_init.sql +12 -55
- package/templates/variants/bun-hono/package.json +12 -5
- package/templates/variants/bun-hono/scripts/migrate.ts +4 -1
- package/templates/variants/bun-hono/src/auth.ts +181 -0
- package/templates/variants/bun-hono/src/db/repository.ts +68 -421
- package/templates/variants/bun-hono/src/db/schema.ts +15 -64
- package/templates/variants/bun-hono/src/index.ts +65 -180
- package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
- package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
- package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
- package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
- package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
- package/templates/variants/bun-hono/test/app.test.ts +72 -41
- package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
- package/templates/variants/go-chi/Makefile +27 -11
- package/templates/variants/go-chi/atlas.hcl +8 -0
- package/templates/variants/go-chi/cmd/server/main.go +21 -10
- package/templates/variants/go-chi/go.mod +1 -3
- package/templates/variants/go-chi/internal/app/service.go +202 -685
- package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
- package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
- package/templates/variants/go-chi/internal/config/config.go +27 -11
- package/templates/variants/go-chi/internal/httpapi/routes.go +78 -157
- package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
- package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
- package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
- package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
- package/templates/variants/go-chi/migrations/0000_init.sql +12 -55
- package/templates/variants/go-chi/migrations/atlas.sum +2 -0
- package/templates/variants/go-chi/package.json +7 -1
- package/templates/variants/go-connectrpc/Makefile +26 -9
- package/templates/variants/go-connectrpc/atlas.hcl +8 -0
- package/templates/variants/go-connectrpc/buf.gen.yaml +2 -2
- package/templates/variants/go-connectrpc/cmd/server/main.go +23 -12
- package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
- package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
- package/templates/variants/go-connectrpc/go.mod +1 -1
- package/templates/variants/go-connectrpc/internal/app/service.go +202 -685
- package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
- package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
- package/templates/variants/go-connectrpc/internal/config/config.go +27 -11
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +78 -201
- package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +147 -9
- package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
- package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
- package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
- package/templates/variants/go-connectrpc/migrations/0000_init.sql +12 -55
- package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
- package/templates/variants/go-connectrpc/package.json +7 -1
- package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
- package/templates/root/.github/workflows/buf-publish.yml +0 -19
- package/templates/root/.github/workflows/ci.yml +0 -26
- package/templates/root/.github/workflows/deploy.yml +0 -22
- package/templates/root/Dockerfile +0 -23
- package/templates/root/README.md +0 -69
- package/templates/root/buf.gen.yaml +0 -10
- package/templates/root/buf.yaml +0 -9
- package/templates/root/cmd/server/main.go +0 -44
- package/templates/root/gen/dns/v1/dns.pb.go +0 -623
- package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/root/go.mod +0 -10
- package/templates/root/internal/app/service.go +0 -152
- package/templates/root/internal/app/token_source.go +0 -50
- package/templates/root/internal/cloudflare/client.go +0 -160
- package/templates/root/internal/config/config.go +0 -55
- package/templates/root/internal/connectapi/handler.go +0 -79
- package/templates/root/internal/httpapi/routes.go +0 -93
- package/templates/root/internal/vault/client.go +0 -148
- package/templates/root/package.json +0 -12
- package/templates/root/protos/dns/v1/dns.proto +0 -58
- package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
- package/templates/root/scripts/cloudrun/config.ts +0 -50
- package/templates/root/scripts/cloudrun/deploy.ts +0 -41
- package/templates/root/scripts/cloudrun/lib.ts +0 -244
- package/templates/root/service.yaml +0 -50
- package/templates/root/test/go.test.ts +0 -19
- package/templates/shared/scripts/cloudrun/integrations.ts +0 -111
- package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +0 -1078
- package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +0 -228
- package/templates/variants/bun-connectrpc/src/chat/service.ts +0 -384
- package/templates/variants/bun-connectrpc/src/chat/types.ts +0 -142
- package/templates/variants/bun-connectrpc/src/storage.ts +0 -72
- package/templates/variants/bun-connectrpc/src/webhooks.ts +0 -35
- package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +0 -182
- package/templates/variants/bun-hono/src/chat/service.ts +0 -384
- package/templates/variants/bun-hono/src/chat/types.ts +0 -142
- package/templates/variants/bun-hono/src/storage.ts +0 -72
- package/templates/variants/bun-hono/src/webhooks.ts +0 -35
- package/templates/variants/bun-hono/test/list-messages.integration.test.ts +0 -256
- package/templates/variants/go-chi/buf.gen.yaml +0 -12
- package/templates/variants/go-chi/buf.yaml +0 -9
- package/templates/variants/go-chi/cmd/migrate/main.go +0 -101
- package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +0 -298
- package/templates/variants/go-chi/protos/chat/v1/chat.proto +0 -219
- package/templates/variants/go-connectrpc/cmd/migrate/main.go +0 -101
- package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +0 -2512
- package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +0 -571
- package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +0 -216
- package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +0 -232
|
@@ -1,384 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,142 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { Storage } from "@google-cloud/storage";
|
|
2
|
-
import type { AttachmentObjectMetadata, AttachmentUploadTarget } from "./chat/types";
|
|
3
|
-
|
|
4
|
-
export type AttachmentStorage = {
|
|
5
|
-
createSignedUpload(input: {
|
|
6
|
-
attachmentId: string;
|
|
7
|
-
conversationId: string;
|
|
8
|
-
filename: string;
|
|
9
|
-
contentType: string;
|
|
10
|
-
}): Promise<{ bucket: string; key: string; upload: AttachmentUploadTarget; publicUrl: string }>;
|
|
11
|
-
getObjectMetadata(input: { bucket: string; key: string }): Promise<AttachmentObjectMetadata>;
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
export class GcsAttachmentStorage implements AttachmentStorage {
|
|
15
|
-
constructor(
|
|
16
|
-
private readonly bucketName = requireAttachmentBucket(),
|
|
17
|
-
private readonly publicBaseUrl = Bun.env.ATTACHMENT_PUBLIC_BASE_URL?.trim() || `https://storage.googleapis.com/${requireAttachmentBucket()}`
|
|
18
|
-
) {}
|
|
19
|
-
|
|
20
|
-
async createSignedUpload(input: { attachmentId: string; conversationId: string; filename: string; contentType: string }) {
|
|
21
|
-
const storage = new Storage();
|
|
22
|
-
const bucket = storage.bucket(this.bucketName);
|
|
23
|
-
const key = `attachments/${input.conversationId}/${input.attachmentId}/${sanitizeFilename(input.filename)}`;
|
|
24
|
-
const file = bucket.file(key);
|
|
25
|
-
const [url] = await file.getSignedUrl({
|
|
26
|
-
action: "write",
|
|
27
|
-
version: "v4",
|
|
28
|
-
expires: Date.now() + 15 * 60 * 1000,
|
|
29
|
-
contentType: input.contentType,
|
|
30
|
-
});
|
|
31
|
-
return {
|
|
32
|
-
bucket: this.bucketName,
|
|
33
|
-
key,
|
|
34
|
-
upload: {
|
|
35
|
-
method: "PUT" as const,
|
|
36
|
-
url,
|
|
37
|
-
headers: {
|
|
38
|
-
"Content-Type": input.contentType,
|
|
39
|
-
},
|
|
40
|
-
},
|
|
41
|
-
publicUrl: `${this.publicBaseUrl.replace(/\/+$/g, "")}/${key}`,
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async getObjectMetadata(input: { bucket: string; key: string }): Promise<AttachmentObjectMetadata> {
|
|
46
|
-
const storage = new Storage();
|
|
47
|
-
const [metadata] = await storage.bucket(input.bucket).file(input.key).getMetadata();
|
|
48
|
-
return {
|
|
49
|
-
bucket: input.bucket,
|
|
50
|
-
key: input.key,
|
|
51
|
-
contentType: String(metadata.contentType ?? ""),
|
|
52
|
-
byteSize: Number(metadata.size ?? 0),
|
|
53
|
-
publicUrl: `${this.publicBaseUrl.replace(/\/+$/g, "")}/${input.key}`,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function createAttachmentStorage() {
|
|
59
|
-
return new GcsAttachmentStorage();
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function requireAttachmentBucket() {
|
|
63
|
-
const bucket = Bun.env.ATTACHMENT_BUCKET?.trim();
|
|
64
|
-
if (!bucket) {
|
|
65
|
-
throw new Error("ATTACHMENT_BUCKET is required");
|
|
66
|
-
}
|
|
67
|
-
return bucket;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function sanitizeFilename(filename: string) {
|
|
71
|
-
return filename.trim().replace(/[^a-zA-Z0-9._-]+/g, "-") || "upload.bin";
|
|
72
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import type { NormalizedWebhookEvent } from "./chat/types";
|
|
2
|
-
|
|
3
|
-
export type WebhookAdapter = {
|
|
4
|
-
normalize(provider: string, headers: Headers, rawBody: string): Promise<NormalizedWebhookEvent>;
|
|
5
|
-
};
|
|
6
|
-
|
|
7
|
-
export class GenericJsonWebhookAdapter implements WebhookAdapter {
|
|
8
|
-
async normalize(provider: string, headers: Headers, rawBody: string): Promise<NormalizedWebhookEvent> {
|
|
9
|
-
const payload = parseJson(rawBody);
|
|
10
|
-
const secret = Bun.env[`WEBHOOK_${provider.toUpperCase()}_SECRET`]?.trim();
|
|
11
|
-
const incomingSecret = headers.get("x-webhook-secret")?.trim() ?? "";
|
|
12
|
-
const externalEventId = String(payload.id ?? headers.get("x-event-id") ?? crypto.randomUUID());
|
|
13
|
-
const eventType = String(payload.type ?? headers.get("x-event-type") ?? "generic.event");
|
|
14
|
-
|
|
15
|
-
return {
|
|
16
|
-
provider,
|
|
17
|
-
externalEventId,
|
|
18
|
-
eventType,
|
|
19
|
-
signatureValid: secret ? incomingSecret === secret : true,
|
|
20
|
-
payloadJson: rawBody,
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function createWebhookAdapter() {
|
|
26
|
-
return new GenericJsonWebhookAdapter();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function parseJson(rawBody: string) {
|
|
30
|
-
try {
|
|
31
|
-
return JSON.parse(rawBody) as Record<string, unknown>;
|
|
32
|
-
} catch {
|
|
33
|
-
return {};
|
|
34
|
-
}
|
|
35
|
-
}
|