@adatechnology/meta-whatsapp-module 0.2.0-rc.11 → 0.2.0-rc.13

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.js CHANGED
@@ -128,6 +128,46 @@ var messages = metaWhatsAppSchema.table("messages", {
128
128
  // tamanho do transcript.
129
129
  index("idx_messages_moderation_flagged").on(table.companyId, table.createdAt).where(sql`${table.moderationFlagged}`)
130
130
  ]);
131
+ var documents = metaWhatsAppSchema.table("documents", {
132
+ id: uuid("id").primaryKey().defaultRandom(),
133
+ companyId: uuid("company_id").notNull(),
134
+ sessionId: uuid("session_id").notNull().references(() => sessions.id, {
135
+ onDelete: "cascade"
136
+ }),
137
+ // Anulável e `set null`: apagar a mensagem não deve apagar o arquivo da biblioteca, e arquivo
138
+ // do atendente nasce sem mensagem.
139
+ messageId: uuid("message_id").references(() => messages.id, {
140
+ onDelete: "set null"
141
+ }),
142
+ uploadId: varchar("upload_id", {
143
+ length: 256
144
+ }).notNull(),
145
+ filename: varchar("filename", {
146
+ length: 512
147
+ }).notNull(),
148
+ mimeType: varchar("mime_type", {
149
+ length: 128
150
+ }).notNull(),
151
+ sizeBytes: integer("size_bytes").notNull(),
152
+ // Do provider de storage, que é endereçado por conteúdo — serve para reconciliar storage
153
+ // contra tabela e para detectar o mesmo binário chegando duas vezes.
154
+ sha256: varchar("sha256", {
155
+ length: 64
156
+ }),
157
+ source: varchar("source", {
158
+ length: 12
159
+ }).notNull(),
160
+ linkedAt: timestamp("linked_at", {
161
+ withTimezone: true
162
+ }).notNull().defaultNow()
163
+ }, (table) => [
164
+ index("idx_documents_session_linked").on(table.sessionId, table.linkedAt),
165
+ // Para varredura de retenção por idade sem passar por sessão.
166
+ index("idx_documents_company_linked").on(table.companyId, table.linkedAt),
167
+ // O mesmo objeto não pode ser linkado duas vezes na mesma empresa: o job de ingestão é
168
+ // reentregue por retry, e sem isto a reentrega criaria linha duplicada no painel.
169
+ uniqueIndex("idx_documents_company_upload").on(table.companyId, table.uploadId)
170
+ ]);
131
171
  var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
132
172
  id: uuid("id").primaryKey().defaultRandom(),
133
173
  companyId: uuid("company_id").notNull(),
@@ -308,6 +348,16 @@ var SessionRepository = class {
308
348
  async release(companyId, whatsappNumber) {
309
349
  await this.setMode(companyId, whatsappNumber, "bot", null);
310
350
  }
351
+ /**
352
+ * Apaga a sessão; a cascata das FKs leva mensagens e documentos.
353
+ *
354
+ * Não apaga o binário no storage — isso é passo de aplicação, e é por isso que este método é
355
+ * chamado por `DeleteConversationUseCase` e não diretamente pelo host. Chamar daqui sem apagar os
356
+ * objetos antes deixa mídia órfã sendo cobrada para sempre.
357
+ */
358
+ async deleteByNumber(companyId, whatsappNumber) {
359
+ await this.db.delete(sessions).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
360
+ }
311
361
  async requestHuman(companyId, whatsappNumber) {
312
362
  await this.db.update(sessions).set({
313
363
  humanRequestedAt: sql2`now()`,
@@ -676,11 +726,13 @@ var SendMessageUseCase = class {
676
726
  sessionRepository;
677
727
  logMessage;
678
728
  objectStorage;
679
- constructor(channel, sessionRepository, logMessage, objectStorage) {
729
+ documentRepository;
730
+ constructor(channel, sessionRepository, logMessage, objectStorage, documentRepository) {
680
731
  this.channel = channel;
681
732
  this.sessionRepository = sessionRepository;
682
733
  this.logMessage = logMessage;
683
734
  this.objectStorage = objectStorage;
735
+ this.documentRepository = documentRepository;
684
736
  }
685
737
  async assertWithinWindow(companyId, whatsappNumber) {
686
738
  const hours = await this.sessionRepository.hoursSinceLastInbound(companyId, whatsappNumber);
@@ -717,7 +769,7 @@ var SendMessageUseCase = class {
717
769
  mimeType: params.mimeType,
718
770
  key: `meta-whatsapp/${params.companyId}/${Date.now()}-${params.filename}`
719
771
  })).uploadId : void 0;
720
- return this.logMessage.execute({
772
+ const saved = await this.logMessage.execute({
721
773
  companyId: params.companyId,
722
774
  whatsappNumber: params.whatsappNumber,
723
775
  direction: "outbound",
@@ -736,6 +788,19 @@ var SendMessageUseCase = class {
736
788
  status: "sent",
737
789
  startState: params.startState
738
790
  });
791
+ if (saved && uploadId) {
792
+ await this.documentRepository?.link({
793
+ companyId: params.companyId,
794
+ sessionId: saved.sessionId,
795
+ messageId: saved.id,
796
+ uploadId,
797
+ filename: params.filename,
798
+ mimeType: params.mimeType,
799
+ sizeBytes: params.buffer.length,
800
+ source: params.sender
801
+ });
802
+ }
803
+ return saved;
739
804
  }
740
805
  // Template é o único envio que ignora a janela — é justamente o mecanismo que a Meta oferece
741
806
  // para reabri-la.
@@ -853,8 +918,330 @@ var ListMessagesUseCase = class {
853
918
  }
854
919
  };
855
920
 
856
- // src/use-cases/ExportConversation.use-case.ts
921
+ // src/use-cases/ListConversationDocuments.use-case.ts
857
922
  import { SessionNotFoundError as SessionNotFoundError2 } from "@adatechnology/meta-whatsapp-contracts";
923
+ var ListConversationDocumentsUseCase = class {
924
+ static {
925
+ __name(this, "ListConversationDocumentsUseCase");
926
+ }
927
+ sessionRepository;
928
+ documentRepository;
929
+ constructor(sessionRepository, documentRepository) {
930
+ this.sessionRepository = sessionRepository;
931
+ this.documentRepository = documentRepository;
932
+ }
933
+ async execute(params) {
934
+ const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
935
+ if (!session) throw new SessionNotFoundError2(params.whatsappNumber);
936
+ const { rows, total } = await this.documentRepository.listByConversation({
937
+ companyId: params.companyId,
938
+ sessionId: session.id,
939
+ ...params.search ? {
940
+ search: params.search
941
+ } : {},
942
+ ...params.sources && params.sources.length > 0 ? {
943
+ sources: params.sources
944
+ } : {},
945
+ ...params.sortDirection ? {
946
+ sortDirection: params.sortDirection
947
+ } : {},
948
+ ...params.page ? {
949
+ page: params.page
950
+ } : {},
951
+ ...params.limit ? {
952
+ limit: params.limit
953
+ } : {}
954
+ });
955
+ return {
956
+ documents: rows.map((row) => ({
957
+ id: row.uploadId,
958
+ filename: row.filename,
959
+ mimeType: row.mimeType,
960
+ sizeBytes: row.sizeBytes,
961
+ source: row.source,
962
+ linkedAt: row.linkedAt.toISOString()
963
+ })),
964
+ total
965
+ };
966
+ }
967
+ };
968
+
969
+ // src/use-cases/ListCompanyDocuments.use-case.ts
970
+ var ListCompanyDocumentsUseCase = class {
971
+ static {
972
+ __name(this, "ListCompanyDocumentsUseCase");
973
+ }
974
+ documentRepository;
975
+ constructor(documentRepository) {
976
+ this.documentRepository = documentRepository;
977
+ }
978
+ async execute(params) {
979
+ const { rows, total } = await this.documentRepository.listByCompany({
980
+ companyId: params.companyId,
981
+ ...params.search ? {
982
+ search: params.search
983
+ } : {},
984
+ ...params.sources && params.sources.length > 0 ? {
985
+ sources: params.sources
986
+ } : {},
987
+ ...params.sortDirection ? {
988
+ sortDirection: params.sortDirection
989
+ } : {},
990
+ ...params.page ? {
991
+ page: params.page
992
+ } : {},
993
+ ...params.limit ? {
994
+ limit: params.limit
995
+ } : {}
996
+ });
997
+ return {
998
+ documents: rows.map((row) => ({
999
+ id: row.uploadId,
1000
+ conversationId: row.whatsappNumber,
1001
+ filename: row.filename,
1002
+ mimeType: row.mimeType,
1003
+ sizeBytes: row.sizeBytes,
1004
+ source: row.source,
1005
+ linkedAt: row.linkedAt.toISOString()
1006
+ })),
1007
+ total
1008
+ };
1009
+ }
1010
+ };
1011
+
1012
+ // src/use-cases/DeleteConversation.use-case.ts
1013
+ import { SessionNotFoundError as SessionNotFoundError3 } from "@adatechnology/meta-whatsapp-contracts";
1014
+ var DeleteConversationUseCase = class {
1015
+ static {
1016
+ __name(this, "DeleteConversationUseCase");
1017
+ }
1018
+ sessionRepository;
1019
+ documentRepository;
1020
+ objectStorage;
1021
+ constructor(sessionRepository, documentRepository, objectStorage) {
1022
+ this.sessionRepository = sessionRepository;
1023
+ this.documentRepository = documentRepository;
1024
+ this.objectStorage = objectStorage;
1025
+ }
1026
+ async execute(params) {
1027
+ const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
1028
+ if (!session) throw new SessionNotFoundError3(params.whatsappNumber);
1029
+ const uploadIds = await this.documentRepository.listUploadIdsBySession(params.companyId, session.id);
1030
+ if (uploadIds.length > 0 && !this.objectStorage?.delete) {
1031
+ throw new Error("storage_delete_unsupported: a conversa tem arquivos e o storage injetado n\xE3o implementa delete");
1032
+ }
1033
+ const failedObjects = [];
1034
+ let deletedObjects = 0;
1035
+ for (const uploadId of uploadIds) {
1036
+ try {
1037
+ await this.objectStorage?.delete?.(uploadId);
1038
+ deletedObjects++;
1039
+ } catch {
1040
+ failedObjects.push(uploadId);
1041
+ }
1042
+ }
1043
+ if (failedObjects.length > 0) return {
1044
+ deletedObjects,
1045
+ failedObjects
1046
+ };
1047
+ await this.sessionRepository.deleteByNumber(params.companyId, params.whatsappNumber);
1048
+ return {
1049
+ deletedObjects,
1050
+ failedObjects: []
1051
+ };
1052
+ }
1053
+ };
1054
+
1055
+ // src/use-cases/PurgeExpiredDocuments.use-case.ts
1056
+ var DEFAULT_BATCH_SIZE = 50;
1057
+ var HOURS_IN_DAY = 24;
1058
+ var MILLISECONDS_IN_HOUR = 60 * 60 * 1e3;
1059
+ var PurgeExpiredDocumentsUseCase = class {
1060
+ static {
1061
+ __name(this, "PurgeExpiredDocumentsUseCase");
1062
+ }
1063
+ documentRepository;
1064
+ objectStorage;
1065
+ constructor(documentRepository, objectStorage) {
1066
+ this.documentRepository = documentRepository;
1067
+ this.objectStorage = objectStorage;
1068
+ }
1069
+ async execute(params) {
1070
+ if (!this.objectStorage?.delete) {
1071
+ throw new Error("storage_delete_unsupported: reten\xE7\xE3o exige um storage que implemente delete");
1072
+ }
1073
+ const reference = params.now ?? /* @__PURE__ */ new Date();
1074
+ const olderThan = new Date(reference.getTime() - params.retentionDays * HOURS_IN_DAY * MILLISECONDS_IN_HOUR);
1075
+ const expired = await this.documentRepository.listExpired(params.companyId, olderThan, params.batchSize ?? DEFAULT_BATCH_SIZE);
1076
+ const failed = [];
1077
+ let purged = 0;
1078
+ for (const document of expired) {
1079
+ try {
1080
+ await this.objectStorage.delete(document.uploadId);
1081
+ } catch {
1082
+ failed.push(document.uploadId);
1083
+ continue;
1084
+ }
1085
+ await this.documentRepository.deleteById(params.companyId, document.id);
1086
+ purged++;
1087
+ }
1088
+ return {
1089
+ purged,
1090
+ failed
1091
+ };
1092
+ }
1093
+ };
1094
+
1095
+ // src/repositories/DocumentRepository.ts
1096
+ import { and as and4, asc, count, desc as desc2, eq as eq5, ilike, inArray, lt as lt2, or } from "drizzle-orm";
1097
+ var DEFAULT_LIMIT3 = 50;
1098
+ function companyDocumentSearch(search) {
1099
+ const term = search?.trim();
1100
+ if (!term) return void 0;
1101
+ const digits = term.replace(/\D/g, "");
1102
+ const byFilename = ilike(documents.filename, `%${term}%`);
1103
+ if (!digits) return byFilename;
1104
+ return or(byFilename, ilike(sessions.whatsappNumber, `%${digits}%`));
1105
+ }
1106
+ __name(companyDocumentSearch, "companyDocumentSearch");
1107
+ var DocumentRepository = class {
1108
+ static {
1109
+ __name(this, "DocumentRepository");
1110
+ }
1111
+ db;
1112
+ constructor(db) {
1113
+ this.db = db;
1114
+ }
1115
+ /**
1116
+ * Idempotente por (companyId, uploadId), garantido pelo índice único e não por SELECT prévio: o
1117
+ * job de ingestão é reentregue por retry e duas tentativas concorrentes passariam as duas por uma
1118
+ * checagem, duplicando a linha no painel.
1119
+ *
1120
+ * Devolve `undefined` quando o documento já estava linkado.
1121
+ */
1122
+ async link(params) {
1123
+ const values = {
1124
+ companyId: params.companyId,
1125
+ sessionId: params.sessionId,
1126
+ messageId: params.messageId ?? null,
1127
+ uploadId: params.uploadId,
1128
+ filename: params.filename,
1129
+ mimeType: params.mimeType,
1130
+ sizeBytes: params.sizeBytes,
1131
+ sha256: params.sha256 ?? null,
1132
+ source: params.source
1133
+ };
1134
+ const [created] = await this.db.insert(documents).values(values).onConflictDoNothing().returning();
1135
+ return created;
1136
+ }
1137
+ async listByConversation(params) {
1138
+ const filters = [
1139
+ eq5(documents.companyId, params.companyId),
1140
+ eq5(documents.sessionId, params.sessionId)
1141
+ ];
1142
+ if (params.search) filters.push(ilike(documents.filename, `%${params.search}%`));
1143
+ if (params.sources && params.sources.length > 0) {
1144
+ filters.push(inArray(documents.source, [
1145
+ ...params.sources
1146
+ ]));
1147
+ }
1148
+ const where = and4(...filters);
1149
+ const limit = params.limit ?? DEFAULT_LIMIT3;
1150
+ const page = params.page && params.page > 0 ? params.page : 1;
1151
+ const orderBy = params.sortDirection === "asc" ? asc(documents.linkedAt) : desc2(documents.linkedAt);
1152
+ const [rows, counted] = await Promise.all([
1153
+ this.db.select().from(documents).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
1154
+ this.db.select({
1155
+ value: count()
1156
+ }).from(documents).where(where)
1157
+ ]);
1158
+ return {
1159
+ rows,
1160
+ total: counted[0]?.value ?? 0
1161
+ };
1162
+ }
1163
+ /**
1164
+ * A biblioteca da EMPRESA inteira, não de uma conversa.
1165
+ *
1166
+ * A busca casa nome do arquivo OU telefone da conversa — ver `companyDocumentSearch`.
1167
+ *
1168
+ * Faz join com `sessions` para carregar de qual conversa cada arquivo veio — numa lista global,
1169
+ * arquivo sem essa referência é inútil: o atendente vê "comprovante.pdf" e não sabe de quem.
1170
+ *
1171
+ * Ordena por `linkedAt` apoiada no índice `idx_documents_company_linked`, que já existia para a
1172
+ * varredura de retenção.
1173
+ */
1174
+ async listByCompany(params) {
1175
+ const filters = [
1176
+ eq5(documents.companyId, params.companyId)
1177
+ ];
1178
+ const search = companyDocumentSearch(params.search);
1179
+ if (search) filters.push(search);
1180
+ if (params.sources && params.sources.length > 0) {
1181
+ filters.push(inArray(documents.source, [
1182
+ ...params.sources
1183
+ ]));
1184
+ }
1185
+ const where = and4(...filters);
1186
+ const limit = params.limit ?? DEFAULT_LIMIT3;
1187
+ const page = params.page && params.page > 0 ? params.page : 1;
1188
+ const orderBy = params.sortDirection === "asc" ? asc(documents.linkedAt) : desc2(documents.linkedAt);
1189
+ const [rows, counted] = await Promise.all([
1190
+ this.db.select({
1191
+ id: documents.id,
1192
+ uploadId: documents.uploadId,
1193
+ filename: documents.filename,
1194
+ mimeType: documents.mimeType,
1195
+ sizeBytes: documents.sizeBytes,
1196
+ source: documents.source,
1197
+ linkedAt: documents.linkedAt,
1198
+ // Só o número: o NOME do cliente é dado do produto (tabela própria dele), não do
1199
+ // módulo. Quem quiser exibir "Marina Alves" enriquece na borda.
1200
+ whatsappNumber: sessions.whatsappNumber
1201
+ }).from(documents).innerJoin(sessions, eq5(documents.sessionId, sessions.id)).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
1202
+ // O mesmo join da listagem, e não só `from(documents)`: a busca pode citar
1203
+ // `sessions.whatsapp_number`, e uma contagem sem a tabela na cláusula não compila — pior,
1204
+ // se compilasse, o total divergiria das linhas e a paginação prometeria páginas vazias.
1205
+ this.db.select({
1206
+ value: count()
1207
+ }).from(documents).innerJoin(sessions, eq5(documents.sessionId, sessions.id)).where(where)
1208
+ ]);
1209
+ return {
1210
+ rows,
1211
+ total: counted[0]?.value ?? 0
1212
+ };
1213
+ }
1214
+ /**
1215
+ * Um documento pela key do objeto. Serve para recuperar o nome original na hora de assinar o
1216
+ * download: a key é caminho no bucket e salvaria o arquivo com o id da Meta.
1217
+ */
1218
+ async findByUploadId(companyId, uploadId) {
1219
+ const [row] = await this.db.select().from(documents).where(and4(eq5(documents.companyId, companyId), eq5(documents.uploadId, uploadId))).limit(1);
1220
+ return row;
1221
+ }
1222
+ /**
1223
+ * Os objetos a apagar no storage antes de a linha sumir.
1224
+ *
1225
+ * Existe porque a cascata da FK apaga a linha e deixa o binário órfão: quem for apagar a conversa
1226
+ * precisa desta lista primeiro, senão paga armazenamento para sempre por arquivo inalcançável.
1227
+ */
1228
+ async listUploadIdsBySession(companyId, sessionId) {
1229
+ const rows = await this.db.select({
1230
+ uploadId: documents.uploadId
1231
+ }).from(documents).where(and4(eq5(documents.companyId, companyId), eq5(documents.sessionId, sessionId)));
1232
+ return rows.map((row) => row.uploadId);
1233
+ }
1234
+ /** Varredura de retenção por idade — o par é o mesmo cuidado com o objeto no storage. */
1235
+ async listExpired(companyId, olderThan, limit = DEFAULT_LIMIT3) {
1236
+ return this.db.select().from(documents).where(and4(eq5(documents.companyId, companyId), lt2(documents.linkedAt, olderThan))).orderBy(documents.linkedAt).limit(limit);
1237
+ }
1238
+ async deleteById(companyId, id) {
1239
+ await this.db.delete(documents).where(and4(eq5(documents.companyId, companyId), eq5(documents.id, id)));
1240
+ }
1241
+ };
1242
+
1243
+ // src/use-cases/ExportConversation.use-case.ts
1244
+ import { SessionNotFoundError as SessionNotFoundError4 } from "@adatechnology/meta-whatsapp-contracts";
858
1245
  var ExportConversationUseCase = class {
859
1246
  static {
860
1247
  __name(this, "ExportConversationUseCase");
@@ -865,7 +1252,7 @@ var ExportConversationUseCase = class {
865
1252
  }
866
1253
  async execute(params) {
867
1254
  const result = await this.sessionRepository.exportConversation(params.companyId, params.whatsappNumber);
868
- if (!result) throw new SessionNotFoundError2(params.whatsappNumber);
1255
+ if (!result) throw new SessionNotFoundError4(params.whatsappNumber);
869
1256
  return result;
870
1257
  }
871
1258
  };
@@ -1197,6 +1584,90 @@ async function claimWebhookDelivery(params) {
1197
1584
  }
1198
1585
  __name(claimWebhookDelivery, "claimWebhookDelivery");
1199
1586
 
1587
+ // src/channel/IngestInboundMedia.use-case.ts
1588
+ import { eq as eq6, and as and5 } from "drizzle-orm";
1589
+ var IngestInboundMediaUseCase = class {
1590
+ static {
1591
+ __name(this, "IngestInboundMediaUseCase");
1592
+ }
1593
+ db;
1594
+ channel;
1595
+ objectStorage;
1596
+ documentRepository;
1597
+ constructor(db, channel, objectStorage, documentRepository) {
1598
+ this.db = db;
1599
+ this.channel = channel;
1600
+ this.objectStorage = objectStorage;
1601
+ this.documentRepository = documentRepository;
1602
+ }
1603
+ async execute(params) {
1604
+ const [message] = await this.db.select().from(messages).where(and5(eq6(messages.companyId, params.companyId), eq6(messages.id, params.messageId))).limit(1);
1605
+ if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
1606
+ const payload = message.payload ?? {};
1607
+ if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
1608
+ return {
1609
+ uploadId: String(payload["uploadId"]),
1610
+ alreadyIngested: true
1611
+ };
1612
+ }
1613
+ const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
1614
+ const buffer = Buffer.from(data, "base64");
1615
+ const { uploadId } = await this.objectStorage.upload({
1616
+ buffer,
1617
+ mimeType: mimeType || params.mimeType,
1618
+ key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
1619
+ });
1620
+ const updatedPayload = {
1621
+ ...payload,
1622
+ uploadId,
1623
+ sourceMediaId: params.sourceMediaId,
1624
+ mimeType: mimeType || params.mimeType,
1625
+ ...params.filename ? {
1626
+ filename: params.filename
1627
+ } : {}
1628
+ };
1629
+ await this.db.update(messages).set({
1630
+ payload: updatedPayload
1631
+ }).where(and5(eq6(messages.companyId, params.companyId), eq6(messages.id, params.messageId)));
1632
+ await this.documentRepository?.link({
1633
+ companyId: params.companyId,
1634
+ sessionId: message.sessionId,
1635
+ messageId: message.id,
1636
+ uploadId,
1637
+ // Áudio e sticker chegam sem nome; sem um rótulo o painel mostraria linha vazia.
1638
+ filename: params.filename ?? `${params.sourceMediaId}`,
1639
+ mimeType: mimeType || params.mimeType,
1640
+ sizeBytes: buffer.length,
1641
+ source: message.sender
1642
+ });
1643
+ return {
1644
+ uploadId,
1645
+ alreadyIngested: false
1646
+ };
1647
+ }
1648
+ };
1649
+ function extractMediaDescriptor(message) {
1650
+ const payload = message.payload ?? {};
1651
+ for (const key of [
1652
+ "image",
1653
+ "audio",
1654
+ "video",
1655
+ "document",
1656
+ "sticker"
1657
+ ]) {
1658
+ const media = payload[key];
1659
+ if (media?.id) {
1660
+ return {
1661
+ sourceMediaId: media.id,
1662
+ mimeType: media.mime_type ?? "application/octet-stream",
1663
+ filename: media.filename
1664
+ };
1665
+ }
1666
+ }
1667
+ return void 0;
1668
+ }
1669
+ __name(extractMediaDescriptor, "extractMediaDescriptor");
1670
+
1200
1671
  // src/channel/ReceiveWebhook.use-case.ts
1201
1672
  function toSessionContract(row) {
1202
1673
  return {
@@ -1309,6 +1780,19 @@ var ReceiveWebhookUseCase = class {
1309
1780
  startState: this.params.startState
1310
1781
  });
1311
1782
  if (!saved) return;
1783
+ const media = extractMediaDescriptor(saved);
1784
+ if (media) {
1785
+ await this.params.hooks?.onMediaReceived?.({
1786
+ companyId,
1787
+ messageId: saved.id,
1788
+ whatsappNumber: message.from,
1789
+ sourceMediaId: media.sourceMediaId,
1790
+ mimeType: media.mimeType,
1791
+ ...media.filename ? {
1792
+ filename: media.filename
1793
+ } : {}
1794
+ });
1795
+ }
1312
1796
  const sessionRow = await this.params.sessionRepository.getContext(companyId, message.from);
1313
1797
  if (!sessionRow) return;
1314
1798
  if (sessionRow.mode === "human") return;
@@ -1328,77 +1812,6 @@ var ReceiveWebhookUseCase = class {
1328
1812
  }
1329
1813
  };
1330
1814
 
1331
- // src/channel/IngestInboundMedia.use-case.ts
1332
- import { eq as eq5, and as and4 } from "drizzle-orm";
1333
- var IngestInboundMediaUseCase = class {
1334
- static {
1335
- __name(this, "IngestInboundMediaUseCase");
1336
- }
1337
- db;
1338
- channel;
1339
- objectStorage;
1340
- constructor(db, channel, objectStorage) {
1341
- this.db = db;
1342
- this.channel = channel;
1343
- this.objectStorage = objectStorage;
1344
- }
1345
- async execute(params) {
1346
- const [message] = await this.db.select().from(messages).where(and4(eq5(messages.companyId, params.companyId), eq5(messages.id, params.messageId))).limit(1);
1347
- if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
1348
- const payload = message.payload ?? {};
1349
- if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
1350
- return {
1351
- uploadId: String(payload["uploadId"]),
1352
- alreadyIngested: true
1353
- };
1354
- }
1355
- const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
1356
- const buffer = Buffer.from(data, "base64");
1357
- const { uploadId } = await this.objectStorage.upload({
1358
- buffer,
1359
- mimeType: mimeType || params.mimeType,
1360
- key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
1361
- });
1362
- const updatedPayload = {
1363
- ...payload,
1364
- uploadId,
1365
- sourceMediaId: params.sourceMediaId,
1366
- mimeType: mimeType || params.mimeType,
1367
- ...params.filename ? {
1368
- filename: params.filename
1369
- } : {}
1370
- };
1371
- await this.db.update(messages).set({
1372
- payload: updatedPayload
1373
- }).where(and4(eq5(messages.companyId, params.companyId), eq5(messages.id, params.messageId)));
1374
- return {
1375
- uploadId,
1376
- alreadyIngested: false
1377
- };
1378
- }
1379
- };
1380
- function extractMediaDescriptor(message) {
1381
- const payload = message.payload ?? {};
1382
- for (const key of [
1383
- "image",
1384
- "audio",
1385
- "video",
1386
- "document",
1387
- "sticker"
1388
- ]) {
1389
- const media = payload[key];
1390
- if (media?.id) {
1391
- return {
1392
- sourceMediaId: media.id,
1393
- mimeType: media.mime_type ?? "application/octet-stream",
1394
- filename: media.filename
1395
- };
1396
- }
1397
- }
1398
- return void 0;
1399
- }
1400
- __name(extractMediaDescriptor, "extractMediaDescriptor");
1401
-
1402
1815
  // src/createMetaWhatsAppModule.ts
1403
1816
  function createMetaWhatsAppModule(params) {
1404
1817
  const { db, config, nonceStore, providers = {}, hooks } = params;
@@ -1416,8 +1829,9 @@ function createMetaWhatsAppModule(params) {
1416
1829
  const messageRepository = new MessageRepository(db);
1417
1830
  const settingsRepository = new SettingsRepository(db);
1418
1831
  const flowGraphRepository = new FlowGraphRepository(db);
1832
+ const documentRepository = new DocumentRepository(db);
1419
1833
  const logMessage = new LogMessageUseCase(sessionRepository, messageRepository, providers.realtime, providers.moderator);
1420
- const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage);
1834
+ const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage, documentRepository);
1421
1835
  const receiveWebhook = new ReceiveWebhookUseCase({
1422
1836
  appSecret: config.appSecret,
1423
1837
  nonceStore,
@@ -1429,7 +1843,8 @@ function createMetaWhatsAppModule(params) {
1429
1843
  realtime: providers.realtime
1430
1844
  });
1431
1845
  const flowInterpreter = flowEngineEnabled ? new FlowInterpreter() : void 0;
1432
- const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage) : void 0;
1846
+ const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage, documentRepository) : void 0;
1847
+ const listDocuments = new ListConversationDocumentsUseCase(sessionRepository, documentRepository);
1433
1848
  return {
1434
1849
  channel,
1435
1850
  // undefined quando providers.objectStorage não foi injetado.
@@ -1441,8 +1856,16 @@ function createMetaWhatsAppModule(params) {
1441
1856
  release: new ReleaseConversationUseCase(sessionRepository, providers.realtime),
1442
1857
  list: new ListConversationsUseCase(sessionRepository),
1443
1858
  listMessages: new ListMessagesUseCase(sessionRepository, messageRepository),
1859
+ listDocuments,
1860
+ // Biblioteca da empresa inteira, para uma tela de Documentos fora da conversa.
1861
+ listCompanyDocuments: new ListCompanyDocumentsUseCase(documentRepository),
1862
+ // Apaga a mídia no storage antes das linhas — a cascata da FK sozinha deixaria os binários
1863
+ // órfãos, já que a lista de uploadId vive justamente nas linhas que ela derruba.
1864
+ delete: new DeleteConversationUseCase(sessionRepository, documentRepository, providers.objectStorage),
1865
+ purgeExpiredDocuments: new PurgeExpiredDocumentsUseCase(documentRepository, providers.objectStorage),
1444
1866
  export: new ExportConversationUseCase(sessionRepository),
1445
- repository: sessionRepository
1867
+ repository: sessionRepository,
1868
+ documentRepository
1446
1869
  },
1447
1870
  settings: settingsRepository,
1448
1871
  webhook: {
@@ -1562,7 +1985,9 @@ async function redeemSseTicket(store, ticket) {
1562
1985
  __name(redeemSseTicket, "redeemSseTicket");
1563
1986
  export {
1564
1987
  CreateFlowGraphUseCase,
1988
+ DeleteConversationUseCase,
1565
1989
  DeleteFlowGraphUseCase,
1990
+ DocumentRepository,
1566
1991
  ExportConversationUseCase,
1567
1992
  FlowGraphRepository,
1568
1993
  FlowInterpreter,
@@ -1570,6 +1995,8 @@ export {
1570
1995
  GetLiveFlowPositionsUseCase,
1571
1996
  IngestInboundMediaUseCase,
1572
1997
  InvalidFlowGraphError,
1998
+ ListCompanyDocumentsUseCase,
1999
+ ListConversationDocumentsUseCase,
1573
2000
  ListConversationsUseCase,
1574
2001
  ListFlowGraphsUseCase,
1575
2002
  ListMessagesUseCase,
@@ -1577,6 +2004,7 @@ export {
1577
2004
  META_WHATSAPP_MIGRATIONS_TABLE,
1578
2005
  MessageRepository,
1579
2006
  OptimisticLockError,
2007
+ PurgeExpiredDocumentsUseCase,
1580
2008
  ReceiveWebhookUseCase,
1581
2009
  ReleaseConversationUseCase,
1582
2010
  SaveFlowGraphUseCase,
@@ -1589,6 +2017,7 @@ export {
1589
2017
  WhatsAppChannelAdapter,
1590
2018
  claimWebhookDelivery,
1591
2019
  createMetaWhatsAppModule,
2020
+ documents,
1592
2021
  extractMediaDescriptor,
1593
2022
  flowGraphs,
1594
2023
  issueSseTicket,