@kaikybrofc/omnizap-system 2.2.3 → 2.2.5

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.
Files changed (55) hide show
  1. package/.env.example +13 -0
  2. package/README.md +29 -85
  3. package/app/controllers/messageController.js +133 -1
  4. package/app/modules/stickerPackModule/catalogHandlers/catalogAdminHttp.js +68 -0
  5. package/app/modules/stickerPackModule/catalogHandlers/catalogAuthHttp.js +34 -0
  6. package/app/modules/stickerPackModule/catalogHandlers/catalogPublicHttp.js +179 -0
  7. package/app/modules/stickerPackModule/catalogHandlers/catalogUploadHttp.js +92 -0
  8. package/app/modules/stickerPackModule/catalogRouter.js +79 -0
  9. package/app/modules/stickerPackModule/domainEventOutboxRepository.js +243 -0
  10. package/app/modules/stickerPackModule/domainEvents.js +61 -0
  11. package/app/modules/stickerPackModule/stickerAssetClassificationRepository.js +21 -0
  12. package/app/modules/stickerPackModule/stickerAssetRepository.js +19 -0
  13. package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +55 -15
  14. package/app/modules/stickerPackModule/stickerDedicatedTaskWorkerRuntime.js +238 -0
  15. package/app/modules/stickerPackModule/stickerDomainEventBus.js +71 -0
  16. package/app/modules/stickerPackModule/stickerDomainEventConsumerRuntime.js +198 -0
  17. package/app/modules/stickerPackModule/stickerObjectStorageService.js +285 -0
  18. package/app/modules/stickerPackModule/stickerPackCatalogHttp.js +1090 -659
  19. package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +19 -1
  20. package/app/modules/stickerPackModule/stickerPackEngagementRepository.js +44 -0
  21. package/app/modules/stickerPackModule/stickerPackItemRepository.js +18 -0
  22. package/app/modules/stickerPackModule/stickerPackRepository.js +51 -0
  23. package/app/modules/stickerPackModule/stickerPackScoreSnapshotRepository.js +191 -0
  24. package/app/modules/stickerPackModule/stickerPackScoreSnapshotRuntime.js +301 -0
  25. package/app/modules/stickerPackModule/stickerStorageService.js +111 -10
  26. package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +21 -0
  27. package/app/modules/stickerPackModule/stickerWorkerTaskQueueRepository.js +59 -7
  28. package/app/observability/metrics.js +169 -0
  29. package/app/services/featureFlagService.js +137 -0
  30. package/app/services/lidMapService.js +4 -1
  31. package/app/services/whatsappLoginLinkService.js +232 -0
  32. package/database/index.js +5 -0
  33. package/database/migrations/20260228_0021_sticker_web_google_owner_phone.sql +83 -0
  34. package/database/migrations/20260228_0022_sticker_scale_indexes.sql +16 -0
  35. package/database/migrations/20260228_0023_sticker_pack_score_snapshot.sql +25 -0
  36. package/database/migrations/20260228_0024_domain_event_outbox.sql +42 -0
  37. package/database/migrations/20260228_0025_sticker_worker_task_idempotency_dlq.sql +23 -0
  38. package/database/migrations/20260228_0026_feature_flags.sql +21 -0
  39. package/ecosystem.prod.config.cjs +70 -9
  40. package/index.js +26 -0
  41. package/package.json +5 -1
  42. package/public/index.html +128 -10
  43. package/public/js/apps/createPackApp.js +59 -272
  44. package/public/js/apps/homeApp.js +106 -0
  45. package/public/js/apps/loginApp.js +459 -0
  46. package/public/js/apps/stickersApp.js +34 -37
  47. package/public/js/apps/userApp.js +244 -0
  48. package/public/js/runtime/react-runtime.js +1 -0
  49. package/public/login/index.html +333 -0
  50. package/public/stickers/create/index.html +2 -1
  51. package/public/stickers/index.html +2 -1
  52. package/public/user/index.html +367 -0
  53. package/scripts/cache-bust.mjs +65 -11
  54. package/scripts/sticker-catalog-loadtest.mjs +208 -0
  55. package/scripts/sticker-worker-task.mjs +122 -0
@@ -0,0 +1,137 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import logger from '../utils/logger/loggerModule.js';
4
+ import { executeQuery, TABLES } from '../../database/index.js';
5
+
6
+ const FEATURE_FLAG_CACHE_TTL_MS = Math.max(
7
+ 5_000,
8
+ Number(process.env.FEATURE_FLAG_CACHE_TTL_MS) || 30_000,
9
+ );
10
+
11
+ let cacheState = {
12
+ loadedAt: 0,
13
+ byName: new Map(),
14
+ tableAvailable: true,
15
+ };
16
+
17
+ const normalizeFlagName = (value) =>
18
+ String(value || '')
19
+ .trim()
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9_:-]/g, '')
22
+ .slice(0, 120);
23
+
24
+ const toPercent = (value) => {
25
+ const numeric = Number(value);
26
+ if (!Number.isFinite(numeric)) return 0;
27
+ return Math.max(0, Math.min(100, Math.floor(numeric)));
28
+ };
29
+
30
+ const toBool = (value, fallback = false) => {
31
+ if (value === true || value === 1) return true;
32
+ if (value === false || value === 0) return false;
33
+ if (value === undefined || value === null || value === '') return fallback;
34
+ const normalized = String(value).trim().toLowerCase();
35
+ if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true;
36
+ if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false;
37
+ return fallback;
38
+ };
39
+
40
+ const normalizeRow = (row) => ({
41
+ flag_name: normalizeFlagName(row?.flag_name),
42
+ is_enabled: toBool(row?.is_enabled, false),
43
+ rollout_percent: toPercent(row?.rollout_percent ?? 100),
44
+ });
45
+
46
+ const resolveCohortBucket = (subjectKey) => {
47
+ const normalized = String(subjectKey || '').trim();
48
+ if (!normalized) return 0;
49
+ const digest = createHash('sha1').update(normalized).digest();
50
+ const value = digest.readUInt32BE(0);
51
+ return value % 100;
52
+ };
53
+
54
+ const loadFlagsFromDatabase = async () => {
55
+ const rows = await executeQuery(
56
+ `SELECT flag_name, is_enabled, rollout_percent
57
+ FROM ${TABLES.FEATURE_FLAG}`,
58
+ [],
59
+ );
60
+
61
+ const byName = new Map();
62
+ (Array.isArray(rows) ? rows : []).forEach((row) => {
63
+ const normalized = normalizeRow(row);
64
+ if (!normalized.flag_name) return;
65
+ byName.set(normalized.flag_name, normalized);
66
+ });
67
+ return byName;
68
+ };
69
+
70
+ export const refreshFeatureFlags = async ({ force = false } = {}) => {
71
+ const now = Date.now();
72
+ const isFresh = now - cacheState.loadedAt < FEATURE_FLAG_CACHE_TTL_MS;
73
+ if (!force && isFresh) return cacheState.byName;
74
+
75
+ if (!cacheState.tableAvailable) return cacheState.byName;
76
+
77
+ try {
78
+ const byName = await loadFlagsFromDatabase();
79
+ cacheState = {
80
+ loadedAt: now,
81
+ byName,
82
+ tableAvailable: true,
83
+ };
84
+ } catch (error) {
85
+ if (error?.code === 'ER_NO_SUCH_TABLE') {
86
+ cacheState = {
87
+ ...cacheState,
88
+ tableAvailable: false,
89
+ };
90
+ logger.warn('Tabela de feature flags indisponível. Usando fallback por env/default.', {
91
+ action: 'feature_flag_table_unavailable',
92
+ });
93
+ return cacheState.byName;
94
+ }
95
+ logger.warn('Falha ao carregar feature flags. Mantendo cache anterior.', {
96
+ action: 'feature_flag_refresh_failed',
97
+ error: error?.message,
98
+ });
99
+ }
100
+
101
+ return cacheState.byName;
102
+ };
103
+
104
+ const resolveEnvFallback = (flagName, fallback) => {
105
+ const envKey = `FEATURE_${flagName.toUpperCase()}`;
106
+ return toBool(process.env[envKey], fallback);
107
+ };
108
+
109
+ export const isFeatureEnabled = async (
110
+ flagName,
111
+ { fallback = false, subjectKey = '' } = {},
112
+ ) => {
113
+ const normalizedFlagName = normalizeFlagName(flagName);
114
+ if (!normalizedFlagName) return Boolean(fallback);
115
+
116
+ const byName = await refreshFeatureFlags();
117
+ const entry = byName.get(normalizedFlagName);
118
+ if (!entry) {
119
+ return resolveEnvFallback(normalizedFlagName, fallback);
120
+ }
121
+
122
+ if (!entry.is_enabled) return false;
123
+ if (entry.rollout_percent >= 100) return true;
124
+ if (entry.rollout_percent <= 0) return false;
125
+
126
+ const bucket = resolveCohortBucket(subjectKey || normalizedFlagName);
127
+ return bucket < entry.rollout_percent;
128
+ };
129
+
130
+ export const getFeatureFlagsSnapshot = async () => {
131
+ const byName = await refreshFeatureFlags();
132
+ return Array.from(byName.values()).map((entry) => ({
133
+ flag_name: entry.flag_name,
134
+ is_enabled: Boolean(entry.is_enabled),
135
+ rollout_percent: Number(entry.rollout_percent || 0),
136
+ }));
137
+ };
@@ -344,7 +344,10 @@ export const extractSenderInfoFromMessage = (msg) => {
344
344
  } else {
345
345
  if (isWhatsAppJid(remoteJid)) jid = remoteJid;
346
346
  if (!jid && isWhatsAppJid(participant)) jid = participant;
347
+ if (!jid && isWhatsAppJid(participantAlt)) jid = participantAlt;
347
348
  if (isLidJid(participant)) lid = participant;
349
+ if (!lid && isLidJid(remoteJid)) lid = remoteJid;
350
+ if (!lid && isLidJid(participantAlt)) lid = participantAlt;
348
351
  }
349
352
 
350
353
  return { lid, jid, participantAlt, remoteJid, groupMessage };
@@ -541,7 +544,7 @@ export const extractUserIdInfo = (value) => {
541
544
  const participantAlt = typeof value.participantAlt === 'string' ? value.participantAlt : null;
542
545
  const participant = typeof value.participant === 'string' ? value.participant : null;
543
546
  const jidCandidate = value.jid || value.id || participantAlt || participant || null;
544
- const lidCandidate = value.lid || participant || null;
547
+ const lidCandidate = value.lid || participant || participantAlt || value.id || value.jid || null;
545
548
 
546
549
  return {
547
550
  lid: pickLid(lidCandidate, participantAlt, participant),
@@ -0,0 +1,232 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { URL } from 'node:url';
3
+ import { getJidServer, getJidUser, normalizeJid } from '../config/baileysConfig.js';
4
+
5
+ const WHATSAPP_USER_SERVERS = new Set(['s.whatsapp.net', 'c.us', 'hosted']);
6
+ const DEFAULT_LOGIN_BASE_URL = 'https://omnizap.shop';
7
+ const SIGNING_SECRET = String(process.env.WHATSAPP_LOGIN_LINK_SECRET || '').trim();
8
+ const SIGNED_LINKS_ENABLED = Boolean(SIGNING_SECRET);
9
+ const REQUIRE_SIGNATURE = parseEnvBool(process.env.WHATSAPP_LOGIN_REQUIRE_SIGNATURE, SIGNED_LINKS_ENABLED);
10
+ const LOGIN_TTL_SECONDS = Math.max(60, Number(process.env.WHATSAPP_LOGIN_LINK_TTL_SECONDS) || 15 * 60);
11
+ const LOGIN_PATH = normalizeLoginPath(process.env.WHATSAPP_LOGIN_PATH || '/login/');
12
+
13
+ function parseEnvBool(value, fallback) {
14
+ if (value === undefined || value === null || value === '') return fallback;
15
+ const normalized = String(value).trim().toLowerCase();
16
+ if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true;
17
+ if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false;
18
+ return fallback;
19
+ }
20
+
21
+ function normalizePhoneDigits(value) {
22
+ return String(value || '').replace(/\D+/g, '');
23
+ }
24
+
25
+ function normalizeLoginPath(value) {
26
+ const raw = String(value || '').trim();
27
+ if (!raw) return '/login/';
28
+ const withLeadingSlash = raw.startsWith('/') ? raw : `/${raw}`;
29
+ return withLeadingSlash.endsWith('/') ? withLeadingSlash : `${withLeadingSlash}/`;
30
+ }
31
+
32
+ function sanitizeTimestamp(value) {
33
+ const parsed = Number(value);
34
+ if (!Number.isFinite(parsed) || parsed <= 0) return 0;
35
+ return Math.floor(parsed);
36
+ }
37
+
38
+ function buildSignaturePayload(phoneDigits, tsSeconds) {
39
+ return `${phoneDigits}.${tsSeconds}`;
40
+ }
41
+
42
+ function buildHintSignature(phoneDigits, tsSeconds) {
43
+ if (!SIGNED_LINKS_ENABLED) return '';
44
+ return createHmac('sha256', SIGNING_SECRET).update(buildSignaturePayload(phoneDigits, tsSeconds)).digest('hex');
45
+ }
46
+
47
+ function safeHexCompare(left, right) {
48
+ const leftHex = String(left || '').trim().toLowerCase();
49
+ const rightHex = String(right || '').trim().toLowerCase();
50
+ if (!leftHex || !rightHex || leftHex.length !== rightHex.length) return false;
51
+
52
+ try {
53
+ const leftBuffer = Buffer.from(leftHex, 'hex');
54
+ const rightBuffer = Buffer.from(rightHex, 'hex');
55
+ if (!leftBuffer.length || leftBuffer.length !== rightBuffer.length) return false;
56
+ return timingSafeEqual(leftBuffer, rightBuffer);
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ function resolveLoginBaseUrl(explicitBaseUrl = '') {
63
+ const candidates = [
64
+ explicitBaseUrl,
65
+ process.env.WHATSAPP_LOGIN_BASE_URL,
66
+ process.env.SITE_ORIGIN,
67
+ process.env.PUBLIC_WEB_BASE_URL,
68
+ DEFAULT_LOGIN_BASE_URL,
69
+ ];
70
+
71
+ for (const candidate of candidates) {
72
+ const raw = String(candidate || '').trim();
73
+ if (!raw) continue;
74
+ try {
75
+ const url = new URL(raw);
76
+ return `${url.origin}`;
77
+ } catch (error) {
78
+ void error;
79
+ }
80
+ }
81
+
82
+ return DEFAULT_LOGIN_BASE_URL;
83
+ }
84
+
85
+ export const toWhatsAppPhoneDigits = (value) => {
86
+ const raw = String(value || '').trim();
87
+ if (!raw) return '';
88
+
89
+ if (raw.includes('@')) {
90
+ const normalizedJid = normalizeJid(raw);
91
+ const server = getJidServer(normalizedJid);
92
+ if (!WHATSAPP_USER_SERVERS.has(server)) return '';
93
+ const jidUser = String(getJidUser(normalizedJid) || '').split(':')[0];
94
+ const digits = normalizePhoneDigits(jidUser);
95
+ return digits.length >= 10 && digits.length <= 15 ? digits : '';
96
+ }
97
+
98
+ const digits = normalizePhoneDigits(raw);
99
+ return digits.length >= 10 && digits.length <= 15 ? digits : '';
100
+ };
101
+
102
+ export const toWhatsAppOwnerJid = (value) => {
103
+ const digits = toWhatsAppPhoneDigits(value);
104
+ if (!digits) return '';
105
+ return normalizeJid(`${digits}@s.whatsapp.net`) || '';
106
+ };
107
+
108
+ export const buildWhatsAppLoginHint = (value, { nowMs = Date.now() } = {}) => {
109
+ const phoneDigits = toWhatsAppPhoneDigits(value);
110
+ if (!phoneDigits) return null;
111
+
112
+ const tsSeconds = Math.floor(nowMs / 1000);
113
+ const hint = {
114
+ wa: phoneDigits,
115
+ wa_ts: String(tsSeconds),
116
+ };
117
+
118
+ const signature = buildHintSignature(phoneDigits, tsSeconds);
119
+ if (signature) hint.wa_sig = signature;
120
+
121
+ return hint;
122
+ };
123
+
124
+ export const buildWhatsAppGoogleLoginUrl = ({ userId, baseUrl } = {}) => {
125
+ const hint = buildWhatsAppLoginHint(userId);
126
+ if (!hint) return '';
127
+
128
+ const root = resolveLoginBaseUrl(baseUrl);
129
+ const url = new URL(LOGIN_PATH, root);
130
+ url.searchParams.set('wa', hint.wa);
131
+ url.searchParams.set('wa_ts', hint.wa_ts);
132
+ if (hint.wa_sig) url.searchParams.set('wa_sig', hint.wa_sig);
133
+ return url.toString();
134
+ };
135
+
136
+ export const extractWhatsAppLoginHint = (payload = {}) => {
137
+ const source = payload && typeof payload === 'object' ? payload : {};
138
+ const nested = source.whatsapp_login && typeof source.whatsapp_login === 'object' ? source.whatsapp_login : {};
139
+ return {
140
+ wa: String(source.wa ?? source.whatsapp_phone ?? source.owner_phone ?? nested.wa ?? nested.phone ?? '').trim(),
141
+ wa_ts: String(source.wa_ts ?? source.whatsapp_ts ?? source.owner_phone_ts ?? nested.wa_ts ?? nested.ts ?? '').trim(),
142
+ wa_sig: String(source.wa_sig ?? source.whatsapp_sig ?? source.owner_phone_sig ?? nested.wa_sig ?? nested.sig ?? '').trim(),
143
+ };
144
+ };
145
+
146
+ export const resolveWhatsAppOwnerJidFromLoginPayload = (payload, { nowMs = Date.now() } = {}) => {
147
+ const hint = extractWhatsAppLoginHint(payload);
148
+ const hasPayload = Boolean(hint.wa || hint.wa_ts || hint.wa_sig);
149
+ if (!hasPayload) {
150
+ return {
151
+ hasPayload: false,
152
+ ownerJid: '',
153
+ verified: false,
154
+ signed: false,
155
+ reason: '',
156
+ };
157
+ }
158
+
159
+ const phoneDigits = toWhatsAppPhoneDigits(hint.wa);
160
+ const ownerJid = toWhatsAppOwnerJid(phoneDigits);
161
+ if (!ownerJid) {
162
+ return {
163
+ hasPayload: true,
164
+ ownerJid: '',
165
+ verified: false,
166
+ signed: false,
167
+ reason: 'invalid_phone',
168
+ };
169
+ }
170
+
171
+ if (!SIGNED_LINKS_ENABLED) {
172
+ return {
173
+ hasPayload: true,
174
+ ownerJid,
175
+ verified: false,
176
+ signed: false,
177
+ reason: '',
178
+ };
179
+ }
180
+
181
+ const tsSeconds = sanitizeTimestamp(hint.wa_ts);
182
+ const hasSignature = Boolean(hint.wa_sig);
183
+
184
+ if (!tsSeconds || !hasSignature) {
185
+ if (REQUIRE_SIGNATURE) {
186
+ return {
187
+ hasPayload: true,
188
+ ownerJid: '',
189
+ verified: false,
190
+ signed: false,
191
+ reason: 'missing_signature',
192
+ };
193
+ }
194
+ return {
195
+ hasPayload: true,
196
+ ownerJid,
197
+ verified: false,
198
+ signed: false,
199
+ reason: '',
200
+ };
201
+ }
202
+
203
+ const nowSeconds = Math.floor(nowMs / 1000);
204
+ if (Math.abs(nowSeconds - tsSeconds) > LOGIN_TTL_SECONDS) {
205
+ return {
206
+ hasPayload: true,
207
+ ownerJid: '',
208
+ verified: false,
209
+ signed: true,
210
+ reason: 'expired',
211
+ };
212
+ }
213
+
214
+ const expectedSignature = buildHintSignature(phoneDigits, tsSeconds);
215
+ if (!safeHexCompare(expectedSignature, hint.wa_sig)) {
216
+ return {
217
+ hasPayload: true,
218
+ ownerJid: '',
219
+ verified: false,
220
+ signed: true,
221
+ reason: 'invalid_signature',
222
+ };
223
+ }
224
+
225
+ return {
226
+ hasPayload: true,
227
+ ownerJid,
228
+ verified: true,
229
+ signed: true,
230
+ reason: '',
231
+ };
232
+ };
package/database/index.js CHANGED
@@ -104,8 +104,13 @@ export const TABLES = {
104
104
  SEMANTIC_THEME_SUGGESTION_CACHE: 'semantic_theme_suggestion_cache',
105
105
  STICKER_PACK_ENGAGEMENT: 'sticker_pack_engagement',
106
106
  STICKER_PACK_INTERACTION_EVENT: 'sticker_pack_interaction_event',
107
+ STICKER_PACK_SCORE_SNAPSHOT: 'sticker_pack_score_snapshot',
107
108
  STICKER_ASSET_REPROCESS_QUEUE: 'sticker_asset_reprocess_queue',
108
109
  STICKER_WORKER_TASK_QUEUE: 'sticker_worker_task_queue',
110
+ STICKER_WORKER_TASK_DLQ: 'sticker_worker_task_dlq',
111
+ DOMAIN_EVENT_OUTBOX: 'domain_event_outbox',
112
+ DOMAIN_EVENT_OUTBOX_DLQ: 'domain_event_outbox_dlq',
113
+ FEATURE_FLAG: 'feature_flag',
109
114
  STICKER_WEB_GOOGLE_USER: 'sticker_web_google_user',
110
115
  STICKER_WEB_GOOGLE_SESSION: 'sticker_web_google_session',
111
116
  STICKER_WEB_ADMIN_BAN: 'sticker_web_admin_ban',
@@ -0,0 +1,83 @@
1
+ SET @db_name = DATABASE();
2
+
3
+ SET @has_user_owner_phone := (
4
+ SELECT COUNT(*)
5
+ FROM INFORMATION_SCHEMA.COLUMNS
6
+ WHERE TABLE_SCHEMA = @db_name
7
+ AND TABLE_NAME = 'sticker_web_google_user'
8
+ AND COLUMN_NAME = 'owner_phone'
9
+ );
10
+ SET @sql_user_owner_phone := IF(
11
+ @has_user_owner_phone = 0,
12
+ 'ALTER TABLE sticker_web_google_user ADD COLUMN owner_phone VARCHAR(20) NULL AFTER owner_jid',
13
+ 'SELECT 1'
14
+ );
15
+ PREPARE stmt_user_owner_phone FROM @sql_user_owner_phone;
16
+ EXECUTE stmt_user_owner_phone;
17
+ DEALLOCATE PREPARE stmt_user_owner_phone;
18
+
19
+ SET @has_user_owner_phone_idx := (
20
+ SELECT COUNT(*)
21
+ FROM INFORMATION_SCHEMA.STATISTICS
22
+ WHERE TABLE_SCHEMA = @db_name
23
+ AND TABLE_NAME = 'sticker_web_google_user'
24
+ AND INDEX_NAME = 'idx_sticker_web_google_user_owner_phone'
25
+ );
26
+ SET @sql_user_owner_phone_idx := IF(
27
+ @has_user_owner_phone_idx = 0,
28
+ 'ALTER TABLE sticker_web_google_user ADD INDEX idx_sticker_web_google_user_owner_phone (owner_phone)',
29
+ 'SELECT 1'
30
+ );
31
+ PREPARE stmt_user_owner_phone_idx FROM @sql_user_owner_phone_idx;
32
+ EXECUTE stmt_user_owner_phone_idx;
33
+ DEALLOCATE PREPARE stmt_user_owner_phone_idx;
34
+
35
+ SET @has_session_owner_phone := (
36
+ SELECT COUNT(*)
37
+ FROM INFORMATION_SCHEMA.COLUMNS
38
+ WHERE TABLE_SCHEMA = @db_name
39
+ AND TABLE_NAME = 'sticker_web_google_session'
40
+ AND COLUMN_NAME = 'owner_phone'
41
+ );
42
+ SET @sql_session_owner_phone := IF(
43
+ @has_session_owner_phone = 0,
44
+ 'ALTER TABLE sticker_web_google_session ADD COLUMN owner_phone VARCHAR(20) NULL AFTER owner_jid',
45
+ 'SELECT 1'
46
+ );
47
+ PREPARE stmt_session_owner_phone FROM @sql_session_owner_phone;
48
+ EXECUTE stmt_session_owner_phone;
49
+ DEALLOCATE PREPARE stmt_session_owner_phone;
50
+
51
+ SET @has_session_owner_phone_idx := (
52
+ SELECT COUNT(*)
53
+ FROM INFORMATION_SCHEMA.STATISTICS
54
+ WHERE TABLE_SCHEMA = @db_name
55
+ AND TABLE_NAME = 'sticker_web_google_session'
56
+ AND INDEX_NAME = 'idx_sticker_web_google_session_owner_phone'
57
+ );
58
+ SET @sql_session_owner_phone_idx := IF(
59
+ @has_session_owner_phone_idx = 0,
60
+ 'ALTER TABLE sticker_web_google_session ADD INDEX idx_sticker_web_google_session_owner_phone (owner_phone)',
61
+ 'SELECT 1'
62
+ );
63
+ PREPARE stmt_session_owner_phone_idx FROM @sql_session_owner_phone_idx;
64
+ EXECUTE stmt_session_owner_phone_idx;
65
+ DEALLOCATE PREPARE stmt_session_owner_phone_idx;
66
+
67
+ UPDATE sticker_web_google_user
68
+ SET owner_phone = CASE
69
+ WHEN SUBSTRING_INDEX(SUBSTRING_INDEX(owner_jid, '@', 1), ':', 1) REGEXP '^[0-9]{10,20}$'
70
+ THEN SUBSTRING_INDEX(SUBSTRING_INDEX(owner_jid, '@', 1), ':', 1)
71
+ ELSE NULL
72
+ END
73
+ WHERE (owner_phone IS NULL OR owner_phone = '')
74
+ AND owner_jid IS NOT NULL;
75
+
76
+ UPDATE sticker_web_google_session
77
+ SET owner_phone = CASE
78
+ WHEN SUBSTRING_INDEX(SUBSTRING_INDEX(owner_jid, '@', 1), ':', 1) REGEXP '^[0-9]{10,20}$'
79
+ THEN SUBSTRING_INDEX(SUBSTRING_INDEX(owner_jid, '@', 1), ':', 1)
80
+ ELSE NULL
81
+ END
82
+ WHERE (owner_phone IS NULL OR owner_phone = '')
83
+ AND owner_jid IS NOT NULL;
@@ -0,0 +1,16 @@
1
+ ALTER TABLE sticker_asset
2
+ ADD INDEX idx_sticker_asset_created_at (created_at);
3
+
4
+ ALTER TABLE sticker_asset_classification
5
+ ADD INDEX idx_sticker_asset_classification_confidence (confidence),
6
+ ADD INDEX idx_sticker_asset_classification_updated_at (updated_at),
7
+ ADD INDEX idx_sticker_asset_classification_version_updated (classification_version, updated_at);
8
+
9
+ ALTER TABLE sticker_pack_item
10
+ ADD INDEX idx_sticker_pack_item_sticker_id (sticker_id);
11
+
12
+ ALTER TABLE sticker_pack
13
+ ADD INDEX idx_sticker_pack_catalog_lookup (deleted_at, status, pack_status, visibility, updated_at);
14
+
15
+ ALTER TABLE sticker_pack_interaction_event
16
+ ADD INDEX idx_sticker_pack_interaction_created_at (created_at);
@@ -0,0 +1,25 @@
1
+ CREATE TABLE IF NOT EXISTS sticker_pack_score_snapshot (
2
+ pack_id CHAR(36) PRIMARY KEY,
3
+ ranking_score DECIMAL(10,6) NOT NULL DEFAULT 0,
4
+ pack_score DECIMAL(10,6) NOT NULL DEFAULT 0,
5
+ trend_score DECIMAL(10,6) NOT NULL DEFAULT 0,
6
+ quality_score DECIMAL(10,6) NOT NULL DEFAULT 0,
7
+ engagement_score DECIMAL(10,6) NOT NULL DEFAULT 0,
8
+ diversity_score DECIMAL(10,6) NOT NULL DEFAULT 0,
9
+ cohesion_score DECIMAL(10,6) NOT NULL DEFAULT 0,
10
+ sensitive_content TINYINT(1) NOT NULL DEFAULT 0,
11
+ nsfw_level ENUM('safe', 'suggestive', 'explicit') NOT NULL DEFAULT 'safe',
12
+ sticker_count INT UNSIGNED NOT NULL DEFAULT 0,
13
+ tags JSON NULL,
14
+ scores_json JSON NULL,
15
+ source_version VARCHAR(32) NOT NULL DEFAULT 'v1',
16
+ refreshed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
17
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
18
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
19
+ CONSTRAINT fk_sticker_pack_score_snapshot_pack
20
+ FOREIGN KEY (pack_id) REFERENCES sticker_pack(id)
21
+ ON DELETE CASCADE ON UPDATE CASCADE,
22
+ INDEX idx_sticker_pack_score_snapshot_ranking (ranking_score),
23
+ INDEX idx_sticker_pack_score_snapshot_trend (trend_score),
24
+ INDEX idx_sticker_pack_score_snapshot_refresh (refreshed_at)
25
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,42 @@
1
+ CREATE TABLE IF NOT EXISTS domain_event_outbox (
2
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
3
+ event_type VARCHAR(96) NOT NULL,
4
+ aggregate_type VARCHAR(96) NOT NULL,
5
+ aggregate_id VARCHAR(128) NOT NULL,
6
+ payload JSON NULL,
7
+ status ENUM('pending', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'pending',
8
+ priority TINYINT UNSIGNED NOT NULL DEFAULT 50,
9
+ idempotency_key VARCHAR(180) NULL,
10
+ available_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
11
+ attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
12
+ max_attempts TINYINT UNSIGNED NOT NULL DEFAULT 10,
13
+ worker_token CHAR(36) NULL,
14
+ last_error VARCHAR(255) NULL,
15
+ locked_at TIMESTAMP NULL DEFAULT NULL,
16
+ processed_at TIMESTAMP NULL DEFAULT NULL,
17
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
18
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
19
+ UNIQUE KEY uq_domain_event_outbox_idempotency_key (idempotency_key),
20
+ INDEX idx_domain_event_outbox_status_sched (status, available_at, priority),
21
+ INDEX idx_domain_event_outbox_event_type (event_type, status, available_at),
22
+ INDEX idx_domain_event_outbox_aggregate (aggregate_type, aggregate_id, created_at),
23
+ INDEX idx_domain_event_outbox_worker_token (worker_token)
24
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
25
+
26
+ CREATE TABLE IF NOT EXISTS domain_event_outbox_dlq (
27
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
28
+ outbox_event_id BIGINT UNSIGNED NULL,
29
+ event_type VARCHAR(96) NOT NULL,
30
+ aggregate_type VARCHAR(96) NOT NULL,
31
+ aggregate_id VARCHAR(128) NOT NULL,
32
+ payload JSON NULL,
33
+ attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
34
+ max_attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
35
+ last_error VARCHAR(255) NULL,
36
+ failed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
37
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
38
+ UNIQUE KEY uq_domain_event_outbox_dlq_outbox_event (outbox_event_id),
39
+ INDEX idx_domain_event_outbox_dlq_event (event_type, failed_at),
40
+ INDEX idx_domain_event_outbox_dlq_aggregate (aggregate_type, aggregate_id, failed_at),
41
+ INDEX idx_domain_event_outbox_dlq_outbox_event_id (outbox_event_id)
42
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,23 @@
1
+ ALTER TABLE sticker_worker_task_queue
2
+ ADD COLUMN IF NOT EXISTS idempotency_key VARCHAR(180) NULL AFTER task_type;
3
+
4
+ CREATE UNIQUE INDEX uq_sticker_worker_task_idempotency_key
5
+ ON sticker_worker_task_queue (idempotency_key);
6
+
7
+ CREATE TABLE IF NOT EXISTS sticker_worker_task_dlq (
8
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
9
+ task_id BIGINT UNSIGNED NULL,
10
+ task_type ENUM('classification_cycle', 'curation_cycle', 'rebuild_cycle') NOT NULL,
11
+ payload JSON NULL,
12
+ idempotency_key VARCHAR(180) NULL,
13
+ attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
14
+ max_attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
15
+ priority TINYINT UNSIGNED NOT NULL DEFAULT 0,
16
+ last_error VARCHAR(255) NULL,
17
+ failed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
18
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
19
+ UNIQUE KEY uq_sticker_worker_task_dlq_task_id (task_id),
20
+ INDEX idx_sticker_worker_task_dlq_type_failed_at (task_type, failed_at),
21
+ INDEX idx_sticker_worker_task_dlq_task_id (task_id),
22
+ INDEX idx_sticker_worker_task_dlq_idempotency_key (idempotency_key)
23
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,21 @@
1
+ CREATE TABLE IF NOT EXISTS feature_flag (
2
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
3
+ flag_name VARCHAR(120) NOT NULL,
4
+ is_enabled TINYINT(1) NOT NULL DEFAULT 0,
5
+ rollout_percent TINYINT UNSIGNED NOT NULL DEFAULT 100,
6
+ description VARCHAR(255) NULL,
7
+ updated_by VARCHAR(120) NULL,
8
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
9
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
10
+ UNIQUE KEY uq_feature_flag_name (flag_name),
11
+ INDEX idx_feature_flag_enabled (is_enabled, rollout_percent)
12
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
13
+
14
+ INSERT INTO feature_flag (flag_name, is_enabled, rollout_percent, description)
15
+ VALUES
16
+ ('enable_ranking_snapshot_read', 1, 100, 'Leitura HTTP do ranking/sinais a partir de snapshot'),
17
+ ('enable_domain_event_outbox', 1, 100, 'Publicacao e consumo de eventos de dominio via outbox interno'),
18
+ ('enable_worker_dedicated_processes', 0, 100, 'Ativa workers dedicados por tipo de task'),
19
+ ('enable_object_storage_delivery', 0, 100, 'Entrega de assets via object storage/CDN com URL segura')
20
+ ON DUPLICATE KEY UPDATE
21
+ description = VALUES(description);