@adatechnology/meta-whatsapp-module 0.2.0-rc.10 → 0.2.0-rc.12

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/dist/index.cjs CHANGED
@@ -8,11 +8,11 @@ var __export = (target, all) => {
8
8
  for (var name in all)
9
9
  __defProp(target, name, { get: all[name], enumerable: true });
10
10
  };
11
- var __copyProps = (to, from, except, desc2) => {
11
+ var __copyProps = (to, from, except, desc3) => {
12
12
  if (from && typeof from === "object" || typeof from === "function") {
13
13
  for (let key of __getOwnPropNames(from))
14
14
  if (!__hasOwnProp.call(to, key) && key !== except)
15
- __defProp(to, key, { get: () => from[key], enumerable: !(desc2 = __getOwnPropDesc(from, key)) || desc2.enumerable });
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc3 = __getOwnPropDesc(from, key)) || desc3.enumerable });
16
16
  }
17
17
  return to;
18
18
  };
@@ -22,7 +22,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
22
22
  var index_exports = {};
23
23
  __export(index_exports, {
24
24
  CreateFlowGraphUseCase: () => CreateFlowGraphUseCase,
25
+ DeleteConversationUseCase: () => DeleteConversationUseCase,
25
26
  DeleteFlowGraphUseCase: () => DeleteFlowGraphUseCase,
27
+ DocumentRepository: () => DocumentRepository,
26
28
  ExportConversationUseCase: () => ExportConversationUseCase,
27
29
  FlowGraphRepository: () => FlowGraphRepository,
28
30
  FlowInterpreter: () => FlowInterpreter,
@@ -30,6 +32,8 @@ __export(index_exports, {
30
32
  GetLiveFlowPositionsUseCase: () => GetLiveFlowPositionsUseCase,
31
33
  IngestInboundMediaUseCase: () => IngestInboundMediaUseCase,
32
34
  InvalidFlowGraphError: () => InvalidFlowGraphError,
35
+ ListCompanyDocumentsUseCase: () => ListCompanyDocumentsUseCase,
36
+ ListConversationDocumentsUseCase: () => ListConversationDocumentsUseCase,
33
37
  ListConversationsUseCase: () => ListConversationsUseCase,
34
38
  ListFlowGraphsUseCase: () => ListFlowGraphsUseCase,
35
39
  ListMessagesUseCase: () => ListMessagesUseCase,
@@ -37,6 +41,7 @@ __export(index_exports, {
37
41
  META_WHATSAPP_MIGRATIONS_TABLE: () => META_WHATSAPP_MIGRATIONS_TABLE,
38
42
  MessageRepository: () => MessageRepository,
39
43
  OptimisticLockError: () => OptimisticLockError,
44
+ PurgeExpiredDocumentsUseCase: () => PurgeExpiredDocumentsUseCase,
40
45
  ReceiveWebhookUseCase: () => ReceiveWebhookUseCase,
41
46
  ReleaseConversationUseCase: () => ReleaseConversationUseCase,
42
47
  SaveFlowGraphUseCase: () => SaveFlowGraphUseCase,
@@ -49,6 +54,7 @@ __export(index_exports, {
49
54
  WhatsAppChannelAdapter: () => WhatsAppChannelAdapter,
50
55
  claimWebhookDelivery: () => claimWebhookDelivery,
51
56
  createMetaWhatsAppModule: () => createMetaWhatsAppModule,
57
+ documents: () => documents,
52
58
  extractMediaDescriptor: () => extractMediaDescriptor,
53
59
  flowGraphs: () => flowGraphs,
54
60
  issueSseTicket: () => issueSseTicket,
@@ -184,6 +190,46 @@ var messages = metaWhatsAppSchema.table("messages", {
184
190
  // tamanho do transcript.
185
191
  (0, import_pg_core.index)("idx_messages_moderation_flagged").on(table.companyId, table.createdAt).where(import_drizzle_orm.sql`${table.moderationFlagged}`)
186
192
  ]);
193
+ var documents = metaWhatsAppSchema.table("documents", {
194
+ id: (0, import_pg_core.uuid)("id").primaryKey().defaultRandom(),
195
+ companyId: (0, import_pg_core.uuid)("company_id").notNull(),
196
+ sessionId: (0, import_pg_core.uuid)("session_id").notNull().references(() => sessions.id, {
197
+ onDelete: "cascade"
198
+ }),
199
+ // Anulável e `set null`: apagar a mensagem não deve apagar o arquivo da biblioteca, e arquivo
200
+ // do atendente nasce sem mensagem.
201
+ messageId: (0, import_pg_core.uuid)("message_id").references(() => messages.id, {
202
+ onDelete: "set null"
203
+ }),
204
+ uploadId: (0, import_pg_core.varchar)("upload_id", {
205
+ length: 256
206
+ }).notNull(),
207
+ filename: (0, import_pg_core.varchar)("filename", {
208
+ length: 512
209
+ }).notNull(),
210
+ mimeType: (0, import_pg_core.varchar)("mime_type", {
211
+ length: 128
212
+ }).notNull(),
213
+ sizeBytes: (0, import_pg_core.integer)("size_bytes").notNull(),
214
+ // Do provider de storage, que é endereçado por conteúdo — serve para reconciliar storage
215
+ // contra tabela e para detectar o mesmo binário chegando duas vezes.
216
+ sha256: (0, import_pg_core.varchar)("sha256", {
217
+ length: 64
218
+ }),
219
+ source: (0, import_pg_core.varchar)("source", {
220
+ length: 12
221
+ }).notNull(),
222
+ linkedAt: (0, import_pg_core.timestamp)("linked_at", {
223
+ withTimezone: true
224
+ }).notNull().defaultNow()
225
+ }, (table) => [
226
+ (0, import_pg_core.index)("idx_documents_session_linked").on(table.sessionId, table.linkedAt),
227
+ // Para varredura de retenção por idade sem passar por sessão.
228
+ (0, import_pg_core.index)("idx_documents_company_linked").on(table.companyId, table.linkedAt),
229
+ // O mesmo objeto não pode ser linkado duas vezes na mesma empresa: o job de ingestão é
230
+ // reentregue por retry, e sem isto a reentrega criaria linha duplicada no painel.
231
+ (0, import_pg_core.uniqueIndex)("idx_documents_company_upload").on(table.companyId, table.uploadId)
232
+ ]);
187
233
  var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
188
234
  id: (0, import_pg_core.uuid)("id").primaryKey().defaultRandom(),
189
235
  companyId: (0, import_pg_core.uuid)("company_id").notNull(),
@@ -364,6 +410,16 @@ var SessionRepository = class {
364
410
  async release(companyId, whatsappNumber) {
365
411
  await this.setMode(companyId, whatsappNumber, "bot", null);
366
412
  }
413
+ /**
414
+ * Apaga a sessão; a cascata das FKs leva mensagens e documentos.
415
+ *
416
+ * Não apaga o binário no storage — isso é passo de aplicação, e é por isso que este método é
417
+ * chamado por `DeleteConversationUseCase` e não diretamente pelo host. Chamar daqui sem apagar os
418
+ * objetos antes deixa mídia órfã sendo cobrada para sempre.
419
+ */
420
+ async deleteByNumber(companyId, whatsappNumber) {
421
+ await this.db.delete(sessions).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(sessions.companyId, companyId), (0, import_drizzle_orm2.eq)(sessions.whatsappNumber, whatsappNumber)));
422
+ }
367
423
  async requestHuman(companyId, whatsappNumber) {
368
424
  await this.db.update(sessions).set({
369
425
  humanRequestedAt: import_drizzle_orm2.sql`now()`,
@@ -732,11 +788,13 @@ var SendMessageUseCase = class {
732
788
  sessionRepository;
733
789
  logMessage;
734
790
  objectStorage;
735
- constructor(channel, sessionRepository, logMessage, objectStorage) {
791
+ documentRepository;
792
+ constructor(channel, sessionRepository, logMessage, objectStorage, documentRepository) {
736
793
  this.channel = channel;
737
794
  this.sessionRepository = sessionRepository;
738
795
  this.logMessage = logMessage;
739
796
  this.objectStorage = objectStorage;
797
+ this.documentRepository = documentRepository;
740
798
  }
741
799
  async assertWithinWindow(companyId, whatsappNumber) {
742
800
  const hours = await this.sessionRepository.hoursSinceLastInbound(companyId, whatsappNumber);
@@ -773,7 +831,7 @@ var SendMessageUseCase = class {
773
831
  mimeType: params.mimeType,
774
832
  key: `meta-whatsapp/${params.companyId}/${Date.now()}-${params.filename}`
775
833
  })).uploadId : void 0;
776
- return this.logMessage.execute({
834
+ const saved = await this.logMessage.execute({
777
835
  companyId: params.companyId,
778
836
  whatsappNumber: params.whatsappNumber,
779
837
  direction: "outbound",
@@ -792,6 +850,19 @@ var SendMessageUseCase = class {
792
850
  status: "sent",
793
851
  startState: params.startState
794
852
  });
853
+ if (saved && uploadId) {
854
+ await this.documentRepository?.link({
855
+ companyId: params.companyId,
856
+ sessionId: saved.sessionId,
857
+ messageId: saved.id,
858
+ uploadId,
859
+ filename: params.filename,
860
+ mimeType: params.mimeType,
861
+ sizeBytes: params.buffer.length,
862
+ source: params.sender
863
+ });
864
+ }
865
+ return saved;
795
866
  }
796
867
  // Template é o único envio que ignora a janela — é justamente o mecanismo que a Meta oferece
797
868
  // para reabri-la.
@@ -909,8 +980,330 @@ var ListMessagesUseCase = class {
909
980
  }
910
981
  };
911
982
 
912
- // src/use-cases/ExportConversation.use-case.ts
983
+ // src/use-cases/ListConversationDocuments.use-case.ts
913
984
  var import_meta_whatsapp_contracts4 = require("@adatechnology/meta-whatsapp-contracts");
985
+ var ListConversationDocumentsUseCase = class {
986
+ static {
987
+ __name(this, "ListConversationDocumentsUseCase");
988
+ }
989
+ sessionRepository;
990
+ documentRepository;
991
+ constructor(sessionRepository, documentRepository) {
992
+ this.sessionRepository = sessionRepository;
993
+ this.documentRepository = documentRepository;
994
+ }
995
+ async execute(params) {
996
+ const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
997
+ if (!session) throw new import_meta_whatsapp_contracts4.SessionNotFoundError(params.whatsappNumber);
998
+ const { rows, total } = await this.documentRepository.listByConversation({
999
+ companyId: params.companyId,
1000
+ sessionId: session.id,
1001
+ ...params.search ? {
1002
+ search: params.search
1003
+ } : {},
1004
+ ...params.sources && params.sources.length > 0 ? {
1005
+ sources: params.sources
1006
+ } : {},
1007
+ ...params.sortDirection ? {
1008
+ sortDirection: params.sortDirection
1009
+ } : {},
1010
+ ...params.page ? {
1011
+ page: params.page
1012
+ } : {},
1013
+ ...params.limit ? {
1014
+ limit: params.limit
1015
+ } : {}
1016
+ });
1017
+ return {
1018
+ documents: rows.map((row) => ({
1019
+ id: row.uploadId,
1020
+ filename: row.filename,
1021
+ mimeType: row.mimeType,
1022
+ sizeBytes: row.sizeBytes,
1023
+ source: row.source,
1024
+ linkedAt: row.linkedAt.toISOString()
1025
+ })),
1026
+ total
1027
+ };
1028
+ }
1029
+ };
1030
+
1031
+ // src/use-cases/ListCompanyDocuments.use-case.ts
1032
+ var ListCompanyDocumentsUseCase = class {
1033
+ static {
1034
+ __name(this, "ListCompanyDocumentsUseCase");
1035
+ }
1036
+ documentRepository;
1037
+ constructor(documentRepository) {
1038
+ this.documentRepository = documentRepository;
1039
+ }
1040
+ async execute(params) {
1041
+ const { rows, total } = await this.documentRepository.listByCompany({
1042
+ companyId: params.companyId,
1043
+ ...params.search ? {
1044
+ search: params.search
1045
+ } : {},
1046
+ ...params.sources && params.sources.length > 0 ? {
1047
+ sources: params.sources
1048
+ } : {},
1049
+ ...params.sortDirection ? {
1050
+ sortDirection: params.sortDirection
1051
+ } : {},
1052
+ ...params.page ? {
1053
+ page: params.page
1054
+ } : {},
1055
+ ...params.limit ? {
1056
+ limit: params.limit
1057
+ } : {}
1058
+ });
1059
+ return {
1060
+ documents: rows.map((row) => ({
1061
+ id: row.uploadId,
1062
+ conversationId: row.whatsappNumber,
1063
+ filename: row.filename,
1064
+ mimeType: row.mimeType,
1065
+ sizeBytes: row.sizeBytes,
1066
+ source: row.source,
1067
+ linkedAt: row.linkedAt.toISOString()
1068
+ })),
1069
+ total
1070
+ };
1071
+ }
1072
+ };
1073
+
1074
+ // src/use-cases/DeleteConversation.use-case.ts
1075
+ var import_meta_whatsapp_contracts5 = require("@adatechnology/meta-whatsapp-contracts");
1076
+ var DeleteConversationUseCase = class {
1077
+ static {
1078
+ __name(this, "DeleteConversationUseCase");
1079
+ }
1080
+ sessionRepository;
1081
+ documentRepository;
1082
+ objectStorage;
1083
+ constructor(sessionRepository, documentRepository, objectStorage) {
1084
+ this.sessionRepository = sessionRepository;
1085
+ this.documentRepository = documentRepository;
1086
+ this.objectStorage = objectStorage;
1087
+ }
1088
+ async execute(params) {
1089
+ const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
1090
+ if (!session) throw new import_meta_whatsapp_contracts5.SessionNotFoundError(params.whatsappNumber);
1091
+ const uploadIds = await this.documentRepository.listUploadIdsBySession(params.companyId, session.id);
1092
+ if (uploadIds.length > 0 && !this.objectStorage?.delete) {
1093
+ throw new Error("storage_delete_unsupported: a conversa tem arquivos e o storage injetado n\xE3o implementa delete");
1094
+ }
1095
+ const failedObjects = [];
1096
+ let deletedObjects = 0;
1097
+ for (const uploadId of uploadIds) {
1098
+ try {
1099
+ await this.objectStorage?.delete?.(uploadId);
1100
+ deletedObjects++;
1101
+ } catch {
1102
+ failedObjects.push(uploadId);
1103
+ }
1104
+ }
1105
+ if (failedObjects.length > 0) return {
1106
+ deletedObjects,
1107
+ failedObjects
1108
+ };
1109
+ await this.sessionRepository.deleteByNumber(params.companyId, params.whatsappNumber);
1110
+ return {
1111
+ deletedObjects,
1112
+ failedObjects: []
1113
+ };
1114
+ }
1115
+ };
1116
+
1117
+ // src/use-cases/PurgeExpiredDocuments.use-case.ts
1118
+ var DEFAULT_BATCH_SIZE = 50;
1119
+ var HOURS_IN_DAY = 24;
1120
+ var MILLISECONDS_IN_HOUR = 60 * 60 * 1e3;
1121
+ var PurgeExpiredDocumentsUseCase = class {
1122
+ static {
1123
+ __name(this, "PurgeExpiredDocumentsUseCase");
1124
+ }
1125
+ documentRepository;
1126
+ objectStorage;
1127
+ constructor(documentRepository, objectStorage) {
1128
+ this.documentRepository = documentRepository;
1129
+ this.objectStorage = objectStorage;
1130
+ }
1131
+ async execute(params) {
1132
+ if (!this.objectStorage?.delete) {
1133
+ throw new Error("storage_delete_unsupported: reten\xE7\xE3o exige um storage que implemente delete");
1134
+ }
1135
+ const reference = params.now ?? /* @__PURE__ */ new Date();
1136
+ const olderThan = new Date(reference.getTime() - params.retentionDays * HOURS_IN_DAY * MILLISECONDS_IN_HOUR);
1137
+ const expired = await this.documentRepository.listExpired(params.companyId, olderThan, params.batchSize ?? DEFAULT_BATCH_SIZE);
1138
+ const failed = [];
1139
+ let purged = 0;
1140
+ for (const document of expired) {
1141
+ try {
1142
+ await this.objectStorage.delete(document.uploadId);
1143
+ } catch {
1144
+ failed.push(document.uploadId);
1145
+ continue;
1146
+ }
1147
+ await this.documentRepository.deleteById(params.companyId, document.id);
1148
+ purged++;
1149
+ }
1150
+ return {
1151
+ purged,
1152
+ failed
1153
+ };
1154
+ }
1155
+ };
1156
+
1157
+ // src/repositories/DocumentRepository.ts
1158
+ var import_drizzle_orm6 = require("drizzle-orm");
1159
+ var DEFAULT_LIMIT3 = 50;
1160
+ function companyDocumentSearch(search) {
1161
+ const term = search?.trim();
1162
+ if (!term) return void 0;
1163
+ const digits = term.replace(/\D/g, "");
1164
+ const byFilename = (0, import_drizzle_orm6.ilike)(documents.filename, `%${term}%`);
1165
+ if (!digits) return byFilename;
1166
+ return (0, import_drizzle_orm6.or)(byFilename, (0, import_drizzle_orm6.ilike)(sessions.whatsappNumber, `%${digits}%`));
1167
+ }
1168
+ __name(companyDocumentSearch, "companyDocumentSearch");
1169
+ var DocumentRepository = class {
1170
+ static {
1171
+ __name(this, "DocumentRepository");
1172
+ }
1173
+ db;
1174
+ constructor(db) {
1175
+ this.db = db;
1176
+ }
1177
+ /**
1178
+ * Idempotente por (companyId, uploadId), garantido pelo índice único e não por SELECT prévio: o
1179
+ * job de ingestão é reentregue por retry e duas tentativas concorrentes passariam as duas por uma
1180
+ * checagem, duplicando a linha no painel.
1181
+ *
1182
+ * Devolve `undefined` quando o documento já estava linkado.
1183
+ */
1184
+ async link(params) {
1185
+ const values = {
1186
+ companyId: params.companyId,
1187
+ sessionId: params.sessionId,
1188
+ messageId: params.messageId ?? null,
1189
+ uploadId: params.uploadId,
1190
+ filename: params.filename,
1191
+ mimeType: params.mimeType,
1192
+ sizeBytes: params.sizeBytes,
1193
+ sha256: params.sha256 ?? null,
1194
+ source: params.source
1195
+ };
1196
+ const [created] = await this.db.insert(documents).values(values).onConflictDoNothing().returning();
1197
+ return created;
1198
+ }
1199
+ async listByConversation(params) {
1200
+ const filters = [
1201
+ (0, import_drizzle_orm6.eq)(documents.companyId, params.companyId),
1202
+ (0, import_drizzle_orm6.eq)(documents.sessionId, params.sessionId)
1203
+ ];
1204
+ if (params.search) filters.push((0, import_drizzle_orm6.ilike)(documents.filename, `%${params.search}%`));
1205
+ if (params.sources && params.sources.length > 0) {
1206
+ filters.push((0, import_drizzle_orm6.inArray)(documents.source, [
1207
+ ...params.sources
1208
+ ]));
1209
+ }
1210
+ const where = (0, import_drizzle_orm6.and)(...filters);
1211
+ const limit = params.limit ?? DEFAULT_LIMIT3;
1212
+ const page = params.page && params.page > 0 ? params.page : 1;
1213
+ const orderBy = params.sortDirection === "asc" ? (0, import_drizzle_orm6.asc)(documents.linkedAt) : (0, import_drizzle_orm6.desc)(documents.linkedAt);
1214
+ const [rows, counted] = await Promise.all([
1215
+ this.db.select().from(documents).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
1216
+ this.db.select({
1217
+ value: (0, import_drizzle_orm6.count)()
1218
+ }).from(documents).where(where)
1219
+ ]);
1220
+ return {
1221
+ rows,
1222
+ total: counted[0]?.value ?? 0
1223
+ };
1224
+ }
1225
+ /**
1226
+ * A biblioteca da EMPRESA inteira, não de uma conversa.
1227
+ *
1228
+ * A busca casa nome do arquivo OU telefone da conversa — ver `companyDocumentSearch`.
1229
+ *
1230
+ * Faz join com `sessions` para carregar de qual conversa cada arquivo veio — numa lista global,
1231
+ * arquivo sem essa referência é inútil: o atendente vê "comprovante.pdf" e não sabe de quem.
1232
+ *
1233
+ * Ordena por `linkedAt` apoiada no índice `idx_documents_company_linked`, que já existia para a
1234
+ * varredura de retenção.
1235
+ */
1236
+ async listByCompany(params) {
1237
+ const filters = [
1238
+ (0, import_drizzle_orm6.eq)(documents.companyId, params.companyId)
1239
+ ];
1240
+ const search = companyDocumentSearch(params.search);
1241
+ if (search) filters.push(search);
1242
+ if (params.sources && params.sources.length > 0) {
1243
+ filters.push((0, import_drizzle_orm6.inArray)(documents.source, [
1244
+ ...params.sources
1245
+ ]));
1246
+ }
1247
+ const where = (0, import_drizzle_orm6.and)(...filters);
1248
+ const limit = params.limit ?? DEFAULT_LIMIT3;
1249
+ const page = params.page && params.page > 0 ? params.page : 1;
1250
+ const orderBy = params.sortDirection === "asc" ? (0, import_drizzle_orm6.asc)(documents.linkedAt) : (0, import_drizzle_orm6.desc)(documents.linkedAt);
1251
+ const [rows, counted] = await Promise.all([
1252
+ this.db.select({
1253
+ id: documents.id,
1254
+ uploadId: documents.uploadId,
1255
+ filename: documents.filename,
1256
+ mimeType: documents.mimeType,
1257
+ sizeBytes: documents.sizeBytes,
1258
+ source: documents.source,
1259
+ linkedAt: documents.linkedAt,
1260
+ // Só o número: o NOME do cliente é dado do produto (tabela própria dele), não do
1261
+ // módulo. Quem quiser exibir "Marina Alves" enriquece na borda.
1262
+ whatsappNumber: sessions.whatsappNumber
1263
+ }).from(documents).innerJoin(sessions, (0, import_drizzle_orm6.eq)(documents.sessionId, sessions.id)).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
1264
+ // O mesmo join da listagem, e não só `from(documents)`: a busca pode citar
1265
+ // `sessions.whatsapp_number`, e uma contagem sem a tabela na cláusula não compila — pior,
1266
+ // se compilasse, o total divergiria das linhas e a paginação prometeria páginas vazias.
1267
+ this.db.select({
1268
+ value: (0, import_drizzle_orm6.count)()
1269
+ }).from(documents).innerJoin(sessions, (0, import_drizzle_orm6.eq)(documents.sessionId, sessions.id)).where(where)
1270
+ ]);
1271
+ return {
1272
+ rows,
1273
+ total: counted[0]?.value ?? 0
1274
+ };
1275
+ }
1276
+ /**
1277
+ * Um documento pela key do objeto. Serve para recuperar o nome original na hora de assinar o
1278
+ * download: a key é caminho no bucket e salvaria o arquivo com o id da Meta.
1279
+ */
1280
+ async findByUploadId(companyId, uploadId) {
1281
+ const [row] = await this.db.select().from(documents).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(documents.companyId, companyId), (0, import_drizzle_orm6.eq)(documents.uploadId, uploadId))).limit(1);
1282
+ return row;
1283
+ }
1284
+ /**
1285
+ * Os objetos a apagar no storage antes de a linha sumir.
1286
+ *
1287
+ * Existe porque a cascata da FK apaga a linha e deixa o binário órfão: quem for apagar a conversa
1288
+ * precisa desta lista primeiro, senão paga armazenamento para sempre por arquivo inalcançável.
1289
+ */
1290
+ async listUploadIdsBySession(companyId, sessionId) {
1291
+ const rows = await this.db.select({
1292
+ uploadId: documents.uploadId
1293
+ }).from(documents).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(documents.companyId, companyId), (0, import_drizzle_orm6.eq)(documents.sessionId, sessionId)));
1294
+ return rows.map((row) => row.uploadId);
1295
+ }
1296
+ /** Varredura de retenção por idade — o par é o mesmo cuidado com o objeto no storage. */
1297
+ async listExpired(companyId, olderThan, limit = DEFAULT_LIMIT3) {
1298
+ return this.db.select().from(documents).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(documents.companyId, companyId), (0, import_drizzle_orm6.lt)(documents.linkedAt, olderThan))).orderBy(documents.linkedAt).limit(limit);
1299
+ }
1300
+ async deleteById(companyId, id) {
1301
+ await this.db.delete(documents).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(documents.companyId, companyId), (0, import_drizzle_orm6.eq)(documents.id, id)));
1302
+ }
1303
+ };
1304
+
1305
+ // src/use-cases/ExportConversation.use-case.ts
1306
+ var import_meta_whatsapp_contracts6 = require("@adatechnology/meta-whatsapp-contracts");
914
1307
  var ExportConversationUseCase = class {
915
1308
  static {
916
1309
  __name(this, "ExportConversationUseCase");
@@ -921,7 +1314,7 @@ var ExportConversationUseCase = class {
921
1314
  }
922
1315
  async execute(params) {
923
1316
  const result = await this.sessionRepository.exportConversation(params.companyId, params.whatsappNumber);
924
- if (!result) throw new import_meta_whatsapp_contracts4.SessionNotFoundError(params.whatsappNumber);
1317
+ if (!result) throw new import_meta_whatsapp_contracts6.SessionNotFoundError(params.whatsappNumber);
925
1318
  return result;
926
1319
  }
927
1320
  };
@@ -1001,7 +1394,7 @@ var GetLiveFlowPositionsUseCase = class {
1001
1394
  };
1002
1395
 
1003
1396
  // src/flows/FlowInterpreter.ts
1004
- var import_meta_whatsapp_contracts5 = require("@adatechnology/meta-whatsapp-contracts");
1397
+ var import_meta_whatsapp_contracts7 = require("@adatechnology/meta-whatsapp-contracts");
1005
1398
  var DEFAULT_MAX_STEPS = 50;
1006
1399
  function evaluateCondition(node, context) {
1007
1400
  if (!node.conditionContextKey || !node.conditionOperator || !node.conditionValue) return false;
@@ -1102,9 +1495,9 @@ var FlowInterpreter = class {
1102
1495
  kind: "terminal",
1103
1496
  context: input.context
1104
1497
  };
1105
- if ((0, import_meta_whatsapp_contracts5.isCrossFlowTarget)(nextNodeId)) return {
1498
+ if ((0, import_meta_whatsapp_contracts7.isCrossFlowTarget)(nextNodeId)) return {
1106
1499
  kind: "cross-flow",
1107
- flowKey: (0, import_meta_whatsapp_contracts5.crossFlowKey)(nextNodeId),
1500
+ flowKey: (0, import_meta_whatsapp_contracts7.crossFlowKey)(nextNodeId),
1108
1501
  context: input.context
1109
1502
  };
1110
1503
  if (!input.graph.nodes[nextNodeId]) return {
@@ -1160,7 +1553,7 @@ var FlowInterpreter = class {
1160
1553
 
1161
1554
  // src/channel/WhatsAppChannelAdapter.ts
1162
1555
  var import_meta_graph_core = require("@adatechnology/meta-graph-core");
1163
- var import_meta_whatsapp_contracts6 = require("@adatechnology/meta-whatsapp-contracts");
1556
+ var import_meta_whatsapp_contracts8 = require("@adatechnology/meta-whatsapp-contracts");
1164
1557
  var WhatsAppChannelAdapter = class {
1165
1558
  static {
1166
1559
  __name(this, "WhatsAppChannelAdapter");
@@ -1173,7 +1566,7 @@ var WhatsAppChannelAdapter = class {
1173
1566
  try {
1174
1567
  return await operation();
1175
1568
  } catch (error) {
1176
- if (error instanceof import_meta_graph_core.WhatsAppWindowExpiredError) throw new import_meta_whatsapp_contracts6.WindowExpiredError();
1569
+ if (error instanceof import_meta_graph_core.WhatsAppWindowExpiredError) throw new import_meta_whatsapp_contracts8.WindowExpiredError();
1177
1570
  throw error;
1178
1571
  }
1179
1572
  }
@@ -1217,11 +1610,11 @@ var WhatsAppChannelAdapter = class {
1217
1610
  };
1218
1611
 
1219
1612
  // src/channel/ReceiveWebhook.use-case.ts
1220
- var import_meta_whatsapp_contracts8 = require("@adatechnology/meta-whatsapp-contracts");
1613
+ var import_meta_whatsapp_contracts10 = require("@adatechnology/meta-whatsapp-contracts");
1221
1614
 
1222
1615
  // src/channel/webhookSecurity.ts
1223
1616
  var import_node_crypto = require("crypto");
1224
- var import_meta_whatsapp_contracts7 = require("@adatechnology/meta-whatsapp-contracts");
1617
+ var import_meta_whatsapp_contracts9 = require("@adatechnology/meta-whatsapp-contracts");
1225
1618
  var WEBHOOK_NONCE_TTL_SECONDS = 300;
1226
1619
  function safeEqualStrings(left, right) {
1227
1620
  const leftDigest = (0, import_node_crypto.createHmac)("sha256", "constant-time-compare").update(left).digest();
@@ -1231,20 +1624,20 @@ function safeEqualStrings(left, right) {
1231
1624
  __name(safeEqualStrings, "safeEqualStrings");
1232
1625
  function verifyWebhookChallenge(params) {
1233
1626
  if (params.mode !== "subscribe" || !params.token || !params.challenge) {
1234
- throw new import_meta_whatsapp_contracts7.InvalidWebhookSignatureError();
1627
+ throw new import_meta_whatsapp_contracts9.InvalidWebhookSignatureError();
1235
1628
  }
1236
1629
  if (!safeEqualStrings(params.token, params.expectedToken)) {
1237
- throw new import_meta_whatsapp_contracts7.InvalidWebhookSignatureError();
1630
+ throw new import_meta_whatsapp_contracts9.InvalidWebhookSignatureError();
1238
1631
  }
1239
1632
  return params.challenge;
1240
1633
  }
1241
1634
  __name(verifyWebhookChallenge, "verifyWebhookChallenge");
1242
1635
  function verifyWebhookSignature(params) {
1243
1636
  const { rawBody, signatureHeader, appSecret } = params;
1244
- if (!signatureHeader?.startsWith("sha256=")) throw new import_meta_whatsapp_contracts7.InvalidWebhookSignatureError();
1637
+ if (!signatureHeader?.startsWith("sha256=")) throw new import_meta_whatsapp_contracts9.InvalidWebhookSignatureError();
1245
1638
  const expected = (0, import_node_crypto.createHmac)("sha256", appSecret).update(rawBody).digest("hex");
1246
1639
  const received = signatureHeader.slice("sha256=".length);
1247
- if (!safeEqualStrings(received, expected)) throw new import_meta_whatsapp_contracts7.InvalidWebhookSignatureError();
1640
+ if (!safeEqualStrings(received, expected)) throw new import_meta_whatsapp_contracts9.InvalidWebhookSignatureError();
1248
1641
  }
1249
1642
  __name(verifyWebhookSignature, "verifyWebhookSignature");
1250
1643
  async function claimWebhookDelivery(params) {
@@ -1253,6 +1646,90 @@ async function claimWebhookDelivery(params) {
1253
1646
  }
1254
1647
  __name(claimWebhookDelivery, "claimWebhookDelivery");
1255
1648
 
1649
+ // src/channel/IngestInboundMedia.use-case.ts
1650
+ var import_drizzle_orm7 = require("drizzle-orm");
1651
+ var IngestInboundMediaUseCase = class {
1652
+ static {
1653
+ __name(this, "IngestInboundMediaUseCase");
1654
+ }
1655
+ db;
1656
+ channel;
1657
+ objectStorage;
1658
+ documentRepository;
1659
+ constructor(db, channel, objectStorage, documentRepository) {
1660
+ this.db = db;
1661
+ this.channel = channel;
1662
+ this.objectStorage = objectStorage;
1663
+ this.documentRepository = documentRepository;
1664
+ }
1665
+ async execute(params) {
1666
+ const [message] = await this.db.select().from(messages).where((0, import_drizzle_orm7.and)((0, import_drizzle_orm7.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm7.eq)(messages.id, params.messageId))).limit(1);
1667
+ if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
1668
+ const payload = message.payload ?? {};
1669
+ if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
1670
+ return {
1671
+ uploadId: String(payload["uploadId"]),
1672
+ alreadyIngested: true
1673
+ };
1674
+ }
1675
+ const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
1676
+ const buffer = Buffer.from(data, "base64");
1677
+ const { uploadId } = await this.objectStorage.upload({
1678
+ buffer,
1679
+ mimeType: mimeType || params.mimeType,
1680
+ key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
1681
+ });
1682
+ const updatedPayload = {
1683
+ ...payload,
1684
+ uploadId,
1685
+ sourceMediaId: params.sourceMediaId,
1686
+ mimeType: mimeType || params.mimeType,
1687
+ ...params.filename ? {
1688
+ filename: params.filename
1689
+ } : {}
1690
+ };
1691
+ await this.db.update(messages).set({
1692
+ payload: updatedPayload
1693
+ }).where((0, import_drizzle_orm7.and)((0, import_drizzle_orm7.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm7.eq)(messages.id, params.messageId)));
1694
+ await this.documentRepository?.link({
1695
+ companyId: params.companyId,
1696
+ sessionId: message.sessionId,
1697
+ messageId: message.id,
1698
+ uploadId,
1699
+ // Áudio e sticker chegam sem nome; sem um rótulo o painel mostraria linha vazia.
1700
+ filename: params.filename ?? `${params.sourceMediaId}`,
1701
+ mimeType: mimeType || params.mimeType,
1702
+ sizeBytes: buffer.length,
1703
+ source: message.sender
1704
+ });
1705
+ return {
1706
+ uploadId,
1707
+ alreadyIngested: false
1708
+ };
1709
+ }
1710
+ };
1711
+ function extractMediaDescriptor(message) {
1712
+ const payload = message.payload ?? {};
1713
+ for (const key of [
1714
+ "image",
1715
+ "audio",
1716
+ "video",
1717
+ "document",
1718
+ "sticker"
1719
+ ]) {
1720
+ const media = payload[key];
1721
+ if (media?.id) {
1722
+ return {
1723
+ sourceMediaId: media.id,
1724
+ mimeType: media.mime_type ?? "application/octet-stream",
1725
+ filename: media.filename
1726
+ };
1727
+ }
1728
+ }
1729
+ return void 0;
1730
+ }
1731
+ __name(extractMediaDescriptor, "extractMediaDescriptor");
1732
+
1256
1733
  // src/channel/ReceiveWebhook.use-case.ts
1257
1734
  function toSessionContract(row) {
1258
1735
  return {
@@ -1330,7 +1807,7 @@ var ReceiveWebhookUseCase = class {
1330
1807
  statusesProcessed: 0
1331
1808
  };
1332
1809
  const rawText = typeof input.rawBody === "string" ? input.rawBody : input.rawBody.toString("utf8");
1333
- const payload = import_meta_whatsapp_contracts8.whatsAppWebhookPayloadSchema.parse(JSON.parse(rawText));
1810
+ const payload = import_meta_whatsapp_contracts10.whatsAppWebhookPayloadSchema.parse(JSON.parse(rawText));
1334
1811
  let messagesProcessed = 0;
1335
1812
  let statusesProcessed = 0;
1336
1813
  for (const entry of payload.entry) {
@@ -1365,6 +1842,19 @@ var ReceiveWebhookUseCase = class {
1365
1842
  startState: this.params.startState
1366
1843
  });
1367
1844
  if (!saved) return;
1845
+ const media = extractMediaDescriptor(saved);
1846
+ if (media) {
1847
+ await this.params.hooks?.onMediaReceived?.({
1848
+ companyId,
1849
+ messageId: saved.id,
1850
+ whatsappNumber: message.from,
1851
+ sourceMediaId: media.sourceMediaId,
1852
+ mimeType: media.mimeType,
1853
+ ...media.filename ? {
1854
+ filename: media.filename
1855
+ } : {}
1856
+ });
1857
+ }
1368
1858
  const sessionRow = await this.params.sessionRepository.getContext(companyId, message.from);
1369
1859
  if (!sessionRow) return;
1370
1860
  if (sessionRow.mode === "human") return;
@@ -1384,77 +1874,6 @@ var ReceiveWebhookUseCase = class {
1384
1874
  }
1385
1875
  };
1386
1876
 
1387
- // src/channel/IngestInboundMedia.use-case.ts
1388
- var import_drizzle_orm6 = require("drizzle-orm");
1389
- var IngestInboundMediaUseCase = class {
1390
- static {
1391
- __name(this, "IngestInboundMediaUseCase");
1392
- }
1393
- db;
1394
- channel;
1395
- objectStorage;
1396
- constructor(db, channel, objectStorage) {
1397
- this.db = db;
1398
- this.channel = channel;
1399
- this.objectStorage = objectStorage;
1400
- }
1401
- async execute(params) {
1402
- const [message] = await this.db.select().from(messages).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm6.eq)(messages.id, params.messageId))).limit(1);
1403
- if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
1404
- const payload = message.payload ?? {};
1405
- if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
1406
- return {
1407
- uploadId: String(payload["uploadId"]),
1408
- alreadyIngested: true
1409
- };
1410
- }
1411
- const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
1412
- const buffer = Buffer.from(data, "base64");
1413
- const { uploadId } = await this.objectStorage.upload({
1414
- buffer,
1415
- mimeType: mimeType || params.mimeType,
1416
- key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
1417
- });
1418
- const updatedPayload = {
1419
- ...payload,
1420
- uploadId,
1421
- sourceMediaId: params.sourceMediaId,
1422
- mimeType: mimeType || params.mimeType,
1423
- ...params.filename ? {
1424
- filename: params.filename
1425
- } : {}
1426
- };
1427
- await this.db.update(messages).set({
1428
- payload: updatedPayload
1429
- }).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm6.eq)(messages.id, params.messageId)));
1430
- return {
1431
- uploadId,
1432
- alreadyIngested: false
1433
- };
1434
- }
1435
- };
1436
- function extractMediaDescriptor(message) {
1437
- const payload = message.payload ?? {};
1438
- for (const key of [
1439
- "image",
1440
- "audio",
1441
- "video",
1442
- "document",
1443
- "sticker"
1444
- ]) {
1445
- const media = payload[key];
1446
- if (media?.id) {
1447
- return {
1448
- sourceMediaId: media.id,
1449
- mimeType: media.mime_type ?? "application/octet-stream",
1450
- filename: media.filename
1451
- };
1452
- }
1453
- }
1454
- return void 0;
1455
- }
1456
- __name(extractMediaDescriptor, "extractMediaDescriptor");
1457
-
1458
1877
  // src/createMetaWhatsAppModule.ts
1459
1878
  function createMetaWhatsAppModule(params) {
1460
1879
  const { db, config, nonceStore, providers = {}, hooks } = params;
@@ -1472,8 +1891,9 @@ function createMetaWhatsAppModule(params) {
1472
1891
  const messageRepository = new MessageRepository(db);
1473
1892
  const settingsRepository = new SettingsRepository(db);
1474
1893
  const flowGraphRepository = new FlowGraphRepository(db);
1894
+ const documentRepository = new DocumentRepository(db);
1475
1895
  const logMessage = new LogMessageUseCase(sessionRepository, messageRepository, providers.realtime, providers.moderator);
1476
- const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage);
1896
+ const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage, documentRepository);
1477
1897
  const receiveWebhook = new ReceiveWebhookUseCase({
1478
1898
  appSecret: config.appSecret,
1479
1899
  nonceStore,
@@ -1485,7 +1905,8 @@ function createMetaWhatsAppModule(params) {
1485
1905
  realtime: providers.realtime
1486
1906
  });
1487
1907
  const flowInterpreter = flowEngineEnabled ? new FlowInterpreter() : void 0;
1488
- const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage) : void 0;
1908
+ const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage, documentRepository) : void 0;
1909
+ const listDocuments = new ListConversationDocumentsUseCase(sessionRepository, documentRepository);
1489
1910
  return {
1490
1911
  channel,
1491
1912
  // undefined quando providers.objectStorage não foi injetado.
@@ -1497,8 +1918,16 @@ function createMetaWhatsAppModule(params) {
1497
1918
  release: new ReleaseConversationUseCase(sessionRepository, providers.realtime),
1498
1919
  list: new ListConversationsUseCase(sessionRepository),
1499
1920
  listMessages: new ListMessagesUseCase(sessionRepository, messageRepository),
1921
+ listDocuments,
1922
+ // Biblioteca da empresa inteira, para uma tela de Documentos fora da conversa.
1923
+ listCompanyDocuments: new ListCompanyDocumentsUseCase(documentRepository),
1924
+ // Apaga a mídia no storage antes das linhas — a cascata da FK sozinha deixaria os binários
1925
+ // órfãos, já que a lista de uploadId vive justamente nas linhas que ela derruba.
1926
+ delete: new DeleteConversationUseCase(sessionRepository, documentRepository, providers.objectStorage),
1927
+ purgeExpiredDocuments: new PurgeExpiredDocumentsUseCase(documentRepository, providers.objectStorage),
1500
1928
  export: new ExportConversationUseCase(sessionRepository),
1501
- repository: sessionRepository
1929
+ repository: sessionRepository,
1930
+ documentRepository
1502
1931
  },
1503
1932
  settings: settingsRepository,
1504
1933
  webhook: {
@@ -1619,7 +2048,9 @@ __name(redeemSseTicket, "redeemSseTicket");
1619
2048
  // Annotate the CommonJS export names for ESM import in node:
1620
2049
  0 && (module.exports = {
1621
2050
  CreateFlowGraphUseCase,
2051
+ DeleteConversationUseCase,
1622
2052
  DeleteFlowGraphUseCase,
2053
+ DocumentRepository,
1623
2054
  ExportConversationUseCase,
1624
2055
  FlowGraphRepository,
1625
2056
  FlowInterpreter,
@@ -1627,6 +2058,8 @@ __name(redeemSseTicket, "redeemSseTicket");
1627
2058
  GetLiveFlowPositionsUseCase,
1628
2059
  IngestInboundMediaUseCase,
1629
2060
  InvalidFlowGraphError,
2061
+ ListCompanyDocumentsUseCase,
2062
+ ListConversationDocumentsUseCase,
1630
2063
  ListConversationsUseCase,
1631
2064
  ListFlowGraphsUseCase,
1632
2065
  ListMessagesUseCase,
@@ -1634,6 +2067,7 @@ __name(redeemSseTicket, "redeemSseTicket");
1634
2067
  META_WHATSAPP_MIGRATIONS_TABLE,
1635
2068
  MessageRepository,
1636
2069
  OptimisticLockError,
2070
+ PurgeExpiredDocumentsUseCase,
1637
2071
  ReceiveWebhookUseCase,
1638
2072
  ReleaseConversationUseCase,
1639
2073
  SaveFlowGraphUseCase,
@@ -1646,6 +2080,7 @@ __name(redeemSseTicket, "redeemSseTicket");
1646
2080
  WhatsAppChannelAdapter,
1647
2081
  claimWebhookDelivery,
1648
2082
  createMetaWhatsAppModule,
2083
+ documents,
1649
2084
  extractMediaDescriptor,
1650
2085
  flowGraphs,
1651
2086
  issueSseTicket,