Commit 39fac7e4 authored by Hoanganhvu123's avatar Hoanganhvu123

feat: port BrightBean Studio social management suite into Canifa pipeline

Phase 2 - AI Content Pipeline & Social Management:

== Backend Modules ==
- common/notification/: Notification engine (in-app, email, webhook, Slack, HMAC-signed)
- common/social/approval_gate.py: Editorial state machine (draft -> review -> approved -> published)
- common/social/post_queue.py: Post queue + calendar slot scheduling
- common/social/scheduler.py: Background publish engine (parallel, exponential backoff)
- common/media/image_processor.py: Media processor (Pillow resize, ffmpeg thumbnails)
- common/content_templates.py: 30+ fashion content templates + Vietnamese RSS feeds (ported from BrightBean builtin_templates.py 968L)

== API Routes ==
- api/notification_route.py: Notification CRUD + test endpoints
- api/content_approval_route.py: Approval workflow (approve/reject/submit)
- api/queue_route.py: Queue + calendar + posting slots
- api/media_route.py: Media library (upload/resize/delete)
- api/templates_route.py: Content templates + RSS feed catalog
- api/social_inbox_route.py: Unified social inbox (FB/IG/TikTok messages)

== Static Dashboards (zero-build) ==
- static/content-approval/index.html: Approval queue UI
- static/content-calendar/index.html: Weekly calendar + queue sidebar
- static/media-library/index.html: Drag-drop upload + asset grid + platform resize
- static/social-inbox/index.html: 3-column inbox (filters/list/detail) + reply/note composer
- static/content-composer/index.html: Post editor + templates + AI enhancement + schedule

== Server ==
- server.py: Registered all new routers + publish engine autostart + dashboard links in banner
parent 5d075d1e
......@@ -196,32 +196,6 @@
"vue-demi": ">=0.13.0"
}
},
"node_modules/@floating-ui/vue/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@internationalized/date": {
"version": "3.12.1",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz",
......@@ -411,9 +385,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
......@@ -431,9 +402,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
......@@ -451,9 +419,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
......@@ -471,9 +436,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
......@@ -491,9 +453,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
......@@ -511,9 +470,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
......@@ -742,9 +698,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
......@@ -762,9 +715,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
......@@ -782,9 +732,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
......@@ -802,9 +749,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
......@@ -1059,10 +1003,13 @@
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/@vue/devtools-kit": {
"version": "7.7.9",
......@@ -1559,9 +1506,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
......@@ -1583,9 +1527,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
......@@ -1607,9 +1548,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
......@@ -1631,9 +1569,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
......@@ -1715,9 +1650,10 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz",
"integrity": "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==",
"dev": true,
"funding": [
{
"type": "github",
......@@ -1726,10 +1662,10 @@
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
"node": "^18 || >=20"
}
},
"node_modules/ohash": {
......@@ -1791,6 +1727,24 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/radix-vue": {
"version": "1.9.17",
"resolved": "https://registry.npmjs.org/radix-vue/-/radix-vue-1.9.17.tgz",
......@@ -1837,33 +1791,6 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/radix-vue/node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/radix-vue/node_modules/@vueuse/metadata": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
......@@ -1887,52 +1814,6 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/radix-vue/node_modules/@vueuse/shared/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/radix-vue/node_modules/nanoid": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz",
"integrity": "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/reka-ui": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.6.tgz",
......@@ -2122,7 +2003,7 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
......@@ -2167,32 +2048,6 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/vaul-vue/node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vaul-vue/node_modules/@vueuse/metadata": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
......@@ -2214,32 +2069,6 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/vaul-vue/node_modules/@vueuse/shared/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vee-validate": {
"version": "4.15.1",
"resolved": "https://registry.npmjs.org/vee-validate/-/vee-validate-4.15.1.tgz",
......@@ -2253,15 +2082,6 @@
"vue": "^3.4.26"
}
},
"node_modules/vee-validate/node_modules/@vue/devtools-api": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/vite": {
"version": "8.0.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
......@@ -2361,6 +2181,32 @@
}
}
},
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vue-input-otp": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/vue-input-otp/-/vue-input-otp-0.3.2.tgz",
......@@ -2425,6 +2271,12 @@
"vue": "^3.5.0"
}
},
"node_modules/vue-router/node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vue-sonner": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/vue-sonner/-/vue-sonner-2.0.9.tgz",
......
......@@ -22,7 +22,9 @@ import { useDark, useToggle } from '@vueuse/core'
const DISABLE_AUTH = import.meta.env.VITE_DISABLE_AUTH === 'true'
const route = useRoute()
const isDark = useDark()
// Default to LIGHT mode — set dark only if user explicitly toggled it
const isDark = useDark({ initialValue: false })
const toggleDark = useToggle(isDark)
// ─── SVG Icon definitions ────────────────────────────
......@@ -214,7 +216,7 @@ const currentBreadcrumb = computed(() => {
<!-- Main App Content Area -->
<SidebarInset>
<header class="flex h-14 shrink-0 items-center justify-between gap-2 border-b bg-background px-4 lg:px-6 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12 sticky top-0 z-50">
<header class="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-border/80 bg-white/90 backdrop-blur-sm px-4 lg:px-6 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12 sticky top-0 z-50">
<!-- Left Side: Trigger + Breadcrumb -->
<div class="flex items-center gap-1.5 lg:gap-2">
......
<template>
<div class="social-inbox-page">
<!-- Header -->
<div class="page-header">
<div class="header-left">
<h1 class="page-title">
<span class="icon">📥</span>
Social Inbox
</h1>
<p class="page-subtitle">Comments & DMs từ Facebook, Instagram, TikTok → AI Learning Loop</p>
</div>
<div class="header-actions">
<button class="btn-refresh" @click="loadData" :disabled="loading">
<span v-if="loading">⏳</span>
<span v-else>🔄</span>
Refresh
</button>
</div>
</div>
<!-- Stats Cards -->
<div class="stats-grid" v-if="stats">
<div class="stat-card total">
<div class="stat-icon">💬</div>
<div class="stat-info">
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">Tổng Messages</div>
</div>
</div>
<div class="stat-card positive">
<div class="stat-icon">🟢</div>
<div class="stat-info">
<div class="stat-value">{{ stats.by_sentiment?.positive || 0 }}</div>
<div class="stat-label">Positive</div>
</div>
</div>
<div class="stat-card negative">
<div class="stat-icon">🔴</div>
<div class="stat-info">
<div class="stat-value">{{ stats.by_sentiment?.negative || 0 }}</div>
<div class="stat-label">Negative</div>
</div>
</div>
<div class="stat-card neutral">
<div class="stat-icon">⚪</div>
<div class="stat-info">
<div class="stat-value">{{ stats.by_sentiment?.neutral || 0 }}</div>
<div class="stat-label">Neutral</div>
</div>
</div>
<div class="stat-card rate" :class="{ 'danger': stats.negative_rate > 30 }">
<div class="stat-icon">📊</div>
<div class="stat-info">
<div class="stat-value">{{ stats.negative_rate }}%</div>
<div class="stat-label">Negative Rate</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="filter-bar">
<div class="filter-tabs">
<button
v-for="p in ['all', 'facebook', 'instagram', 'tiktok']"
:key="p"
class="filter-tab"
:class="{ active: selectedPlatform === p }"
@click="selectPlatform(p)"
>
<span>{{ platformIcon(p) }}</span>
{{ p.charAt(0).toUpperCase() + p.slice(1) }}
<span class="badge" v-if="platformCount(p) > 0">{{ platformCount(p) }}</span>
</button>
</div>
<div class="sentiment-tabs">
<button
v-for="s in ['all', 'positive', 'negative', 'neutral']"
:key="s"
class="sentiment-tab"
:class="[s, { active: selectedSentiment === s }]"
@click="selectSentiment(s)"
>
{{ sentimentIcon(s) }} {{ s }}
</button>
</div>
</div>
<!-- Test Sentiment Tool -->
<div class="sentiment-tester">
<div class="tester-header" @click="showTester = !showTester">
<span>🧪 Test Sentiment Analysis</span>
<span>{{ showTester ? '▲' : '▼' }}</span>
</div>
<div class="tester-body" v-if="showTester">
<textarea
v-model="testText"
placeholder="Nhập text tiếng Việt hoặc tiếng Anh để test..."
class="tester-input"
rows="2"
/>
<button class="btn-analyze" @click="testSentiment">Phân Tích</button>
<div class="tester-result" v-if="testResult">
<span class="result-label" :class="testResult.label">{{ sentimentIcon(testResult.label) }} {{ testResult.label }}</span>
<span class="result-score">Pos: {{ testResult.pos_score }} | Neg: {{ testResult.neg_score }}</span>
<div class="result-keywords" v-if="testResult.matched_positive?.length">
✅ <span>{{ testResult.matched_positive.join(', ') }}</span>
</div>
<div class="result-keywords neg" v-if="testResult.matched_negative?.length">
❌ <span>{{ testResult.matched_negative.join(', ') }}</span>
</div>
</div>
</div>
</div>
<!-- Messages Feed -->
<div class="messages-section">
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<p>Đang tải messages...</p>
</div>
<div v-else-if="messages.length === 0" class="empty-state">
<div class="empty-icon">📭</div>
<p>Chưa có social messages nào.</p>
<p class="empty-hint">Kết nối Facebook/Instagram/TikTok webhook để bắt đầu nhận feedback.</p>
</div>
<div v-else class="messages-list">
<div
v-for="msg in messages"
:key="`${msg.platform}-${msg.received_at}`"
class="message-card"
:class="msg.sentiment"
>
<div class="message-header">
<div class="sender-info">
<span class="platform-badge" :class="msg.platform">
{{ platformIcon(msg.platform) }} {{ msg.platform }}
</span>
<span class="message-type-badge">{{ msg.message_type }}</span>
<strong class="sender-name">@{{ msg.sender_name }}</strong>
</div>
<div class="message-meta">
<span class="sentiment-badge" :class="msg.sentiment">
{{ sentimentIcon(msg.sentiment) }} {{ msg.sentiment }}
</span>
<span class="time">{{ formatTime(msg.received_at) }}</span>
</div>
</div>
<div class="message-body">{{ msg.body }}</div>
<div class="message-footer" v-if="msg.post_id">
<span class="post-id">Post: {{ msg.post_id }}</span>
</div>
</div>
</div>
<!-- Pagination -->
<div class="pagination" v-if="total > limit">
<button @click="prevPage" :disabled="offset === 0">← Trước</button>
<span>{{ Math.floor(offset / limit) + 1 }} / {{ Math.ceil(total / limit) }}</span>
<button @click="nextPage" :disabled="offset + limit >= total">Tiếp →</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
const API_BASE = import.meta.env.VITE_API_URL || ''
const loading = ref(false)
const messages = ref([])
const stats = ref(null)
const total = ref(0)
const limit = ref(50)
const offset = ref(0)
const selectedPlatform = ref('all')
const selectedSentiment = ref('all')
const showTester = ref(false)
const testText = ref('')
const testResult = ref(null)
const platformIcon = (p) => ({ all: '🌐', facebook: '📘', instagram: '📸', tiktok: '🎵' }[p] || '💬')
const sentimentIcon = (s) => ({ all: '🔍', positive: '🟢', negative: '🔴', neutral: '⚪' }[s] || '⚪')
const platformCount = (p) => {
if (!stats.value || p === 'all') return 0
return stats.value.by_platform?.[p]?.total || 0
}
const formatTime = (iso) => {
if (!iso) return ''
const d = new Date(iso)
return d.toLocaleString('vi-VN', { hour: '2-digit', minute: '2-digit', day: '2-digit', month: '2-digit' })
}
const selectPlatform = (p) => { selectedPlatform.value = p; offset.value = 0; loadMessages() }
const selectSentiment = (s) => { selectedSentiment.value = s; offset.value = 0; loadMessages() }
const loadMessages = async () => {
loading.value = true
try {
const params = new URLSearchParams({ limit: limit.value, offset: offset.value })
if (selectedPlatform.value !== 'all') params.append('platform', selectedPlatform.value)
if (selectedSentiment.value !== 'all') params.append('sentiment', selectedSentiment.value)
const res = await fetch(`${API_BASE}/api/social/messages?${params}`)
const data = await res.json()
messages.value = data.messages || []
total.value = data.total || 0
} catch (e) {
console.error('Failed to load messages:', e)
} finally {
loading.value = false
}
}
const loadStats = async () => {
try {
const res = await fetch(`${API_BASE}/api/social/messages/stats`)
stats.value = await res.json()
} catch (e) {
console.error('Failed to load stats:', e)
}
}
const loadData = async () => {
await Promise.all([loadMessages(), loadStats()])
}
const testSentiment = async () => {
if (!testText.value.trim()) return
try {
const res = await fetch(`${API_BASE}/api/social/messages/analyze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: testText.value }),
})
testResult.value = await res.json()
} catch (e) {
console.error('Sentiment test failed:', e)
}
}
const prevPage = () => { offset.value = Math.max(0, offset.value - limit.value); loadMessages() }
const nextPage = () => { offset.value += limit.value; loadMessages() }
onMounted(loadData)
</script>
<style scoped>
.social-inbox-page { padding: 24px; max-width: 1200px; margin: 0 auto; font-family: 'Inter', sans-serif; }
.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }
.page-title { font-size: 1.8rem; font-weight: 700; color: #1a1a2e; margin: 0; }
.page-title .icon { margin-right: 8px; }
.page-subtitle { color: #666; margin: 4px 0 0; font-size: 0.9rem; }
.btn-refresh { background: #4f46e5; color: white; border: none; padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 0.9rem; transition: all 0.2s; }
.btn-refresh:hover { background: #4338ca; transform: translateY(-1px); }
.btn-refresh:disabled { opacity: 0.6; cursor: not-allowed; }
/* Stats */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 24px; }
.stat-card { background: white; border-radius: 12px; padding: 16px; display: flex; gap: 12px; align-items: center; box-shadow: 0 2px 8px rgba(0,0,0,0.08); border-left: 4px solid #e5e7eb; }
.stat-card.positive { border-color: #22c55e; }
.stat-card.negative { border-color: #ef4444; }
.stat-card.neutral { border-color: #9ca3af; }
.stat-card.total { border-color: #6366f1; }
.stat-card.rate { border-color: #f59e0b; }
.stat-card.rate.danger { border-color: #ef4444; background: #fef2f2; }
.stat-icon { font-size: 1.5rem; }
.stat-value { font-size: 1.6rem; font-weight: 700; color: #1a1a2e; }
.stat-label { font-size: 0.75rem; color: #888; }
/* Filters */
.filter-bar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 20px; background: white; padding: 12px 16px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.filter-tabs, .sentiment-tabs { display: flex; gap: 6px; }
.filter-tab, .sentiment-tab { padding: 6px 14px; border-radius: 20px; border: 1px solid #e5e7eb; background: white; cursor: pointer; font-size: 0.85rem; transition: all 0.2s; display: flex; align-items: center; gap: 6px; }
.filter-tab:hover, .sentiment-tab:hover { background: #f5f5f5; }
.filter-tab.active { background: #4f46e5; color: white; border-color: #4f46e5; }
.sentiment-tab.positive.active { background: #22c55e; color: white; border-color: #22c55e; }
.sentiment-tab.negative.active { background: #ef4444; color: white; border-color: #ef4444; }
.sentiment-tab.neutral.active { background: #9ca3af; color: white; border-color: #9ca3af; }
.sentiment-tab.all.active { background: #4f46e5; color: white; border-color: #4f46e5; }
.badge { background: #ef4444; color: white; border-radius: 10px; padding: 1px 7px; font-size: 0.75rem; }
/* Sentiment Tester */
.sentiment-tester { background: white; border-radius: 12px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); overflow: hidden; }
.tester-header { padding: 12px 16px; cursor: pointer; display: flex; justify-content: space-between; font-weight: 600; font-size: 0.9rem; color: #4f46e5; background: #f0f0ff; }
.tester-body { padding: 16px; display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-start; }
.tester-input { flex: 1; min-width: 200px; border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px; font-size: 0.9rem; resize: vertical; }
.btn-analyze { background: #6366f1; color: white; border: none; padding: 10px 18px; border-radius: 8px; cursor: pointer; white-space: nowrap; }
.tester-result { width: 100%; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; padding: 10px; background: #f9fafb; border-radius: 8px; }
.result-label { font-weight: 700; padding: 4px 12px; border-radius: 20px; font-size: 0.9rem; }
.result-label.positive { background: #dcfce7; color: #16a34a; }
.result-label.negative { background: #fee2e2; color: #dc2626; }
.result-label.neutral { background: #f3f4f6; color: #6b7280; }
.result-score { font-size: 0.85rem; color: #666; }
.result-keywords { font-size: 0.82rem; color: #555; }
.result-keywords.neg { color: #dc2626; }
/* Messages */
.messages-section { margin-top: 8px; }
.messages-list { display: flex; flex-direction: column; gap: 12px; }
.message-card { background: white; border-radius: 12px; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); border-left: 4px solid #e5e7eb; transition: transform 0.15s; }
.message-card:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0,0,0,0.1); }
.message-card.positive { border-color: #22c55e; }
.message-card.negative { border-color: #ef4444; }
.message-card.neutral { border-color: #d1d5db; }
.message-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; flex-wrap: wrap; gap: 8px; }
.sender-info { display: flex; align-items: center; gap: 8px; }
.platform-badge { font-size: 0.78rem; padding: 2px 10px; border-radius: 12px; font-weight: 600; }
.platform-badge.facebook { background: #e7f0fd; color: #1877f2; }
.platform-badge.instagram { background: #fde7f5; color: #e1306c; }
.platform-badge.tiktok { background: #e7faf5; color: #00c4cc; }
.message-type-badge { font-size: 0.75rem; background: #f3f4f6; padding: 2px 8px; border-radius: 10px; color: #666; }
.sender-name { font-weight: 600; font-size: 0.9rem; }
.message-meta { display: flex; align-items: center; gap: 8px; }
.sentiment-badge { font-size: 0.78rem; padding: 2px 10px; border-radius: 12px; font-weight: 600; }
.sentiment-badge.positive { background: #dcfce7; color: #16a34a; }
.sentiment-badge.negative { background: #fee2e2; color: #dc2626; }
.sentiment-badge.neutral { background: #f3f4f6; color: #6b7280; }
.time { font-size: 0.78rem; color: #aaa; }
.message-body { font-size: 0.95rem; color: #333; line-height: 1.5; }
.post-id { font-size: 0.75rem; color: #bbb; margin-top: 6px; }
/* Empty / Loading */
.empty-state, .loading-state { text-align: center; padding: 60px 20px; color: #888; }
.empty-icon { font-size: 3rem; margin-bottom: 12px; }
.empty-hint { font-size: 0.85rem; color: #aaa; }
.spinner { width: 36px; height: 36px; border: 3px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.8s linear infinite; margin: 0 auto 12px; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Pagination */
.pagination { display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 20px; }
.pagination button { padding: 8px 20px; border-radius: 8px; border: 1px solid #e5e7eb; background: white; cursor: pointer; }
.pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
</style>
......@@ -18,6 +18,7 @@ const routes = [
{ path: '/home/product', component: () => import('../pages/product.vue'), meta: { requiresAuth: true } },
{ path: '/home/product-desc', component: () => import('../pages/product-desc.vue'), meta: { requiresAuth: true } },
{ path: '/home/fashion-matches',component: () => import('../pages/fashion-matches.vue'), meta: { requiresAuth: true } },
{ path: '/home/social-inbox', component: () => import('../pages/social-inbox.vue'), meta: { requiresAuth: true } },
{ path: '/home/ai-report', component: () => import('../pages/ai-report.vue'), meta: { requiresAuth: true } },
{ path: '/home/ai-sql', component: () => import('../pages/ai-sql.vue'), meta: { requiresAuth: true } },
{ path: '/home/live-monitor', component: () => import('../pages/live-monitor.vue'), meta: { requiresAuth: true } },
......
......@@ -120,17 +120,51 @@
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
/* ── Sidebar (dark, handled by DashboardLayout inline styles) ── */
--sidebar: oklch(0.100 0.008 260);
--sidebar-foreground: oklch(0.920 0.006 248);
/* ── Sidebar — clean white, shadcn/ui style ── */
--sidebar: oklch(0.992 0.002 240);
--sidebar-foreground: oklch(0.200 0.012 260);
--sidebar-primary: oklch(0.680 0.165 55);
--sidebar-primary-foreground: oklch(0.980 0.005 80);
--sidebar-accent: oklch(0.180 0.010 260);
--sidebar-accent-foreground: oklch(0.920 0.006 248);
--sidebar-border: oklch(0.200 0.010 260);
--sidebar-accent: oklch(0.960 0.006 248);
--sidebar-accent-foreground: oklch(0.200 0.012 260);
--sidebar-border: oklch(0.920 0.006 248);
--sidebar-ring: oklch(0.680 0.165 55);
}
/* ══════════════════════════════════════════════════════════════════════════
DARK THEME — activated via .dark class on <html> by VueUse useDark()
══════════════════════════════════════════════════════════════════════════ */
.dark {
color-scheme: dark;
--background: oklch(0.095 0.010 260);
--foreground: oklch(0.930 0.006 248);
--card: oklch(0.130 0.010 260);
--card-foreground: oklch(0.930 0.006 248);
--popover: oklch(0.130 0.010 260);
--popover-foreground: oklch(0.930 0.006 248);
--primary: oklch(0.720 0.160 55);
--primary-foreground: oklch(0.100 0.008 260);
--secondary: oklch(0.190 0.012 260);
--secondary-foreground: oklch(0.930 0.006 248);
--muted: oklch(0.190 0.010 260);
--muted-foreground: oklch(0.580 0.012 258);
--accent: oklch(0.200 0.015 60);
--accent-foreground: oklch(0.930 0.006 248);
--destructive: oklch(0.520 0.220 27);
--destructive-foreground: oklch(0.930 0.006 248);
--border: oklch(0.220 0.012 260);
--input: oklch(0.220 0.012 260);
--ring: oklch(0.720 0.160 55);
--sidebar: oklch(0.110 0.010 260);
--sidebar-foreground: oklch(0.900 0.006 248);
--sidebar-primary: oklch(0.720 0.160 55);
--sidebar-primary-foreground: oklch(0.100 0.008 260);
--sidebar-accent: oklch(0.180 0.012 260);
--sidebar-accent-foreground: oklch(0.900 0.006 248);
--sidebar-border: oklch(0.210 0.012 260);
--sidebar-ring: oklch(0.720 0.160 55);
}
/* ══════════════════════════════════════════════════════════════════════════
BASE LAYER
══════════════════════════════════════════════════════════════════════════ */
......
"""
Content Approval REST API Route
POST /api/content — tạo content mới (AI agent)
GET /api/content — list content (filter by status)
GET /api/content/{id} — chi tiết content
POST /api/content/{id}/submit — submit for review
POST /api/content/{id}/approve — marketing approve
POST /api/content/{id}/reject — marketing reject
POST /api/content/{id}/changes — request changes
POST /api/content/{id}/schedule — assign vào queue
POST /api/content/{id}/publish-now — publish ngay lập tức
GET /api/content/stats — approval stats
"""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from common.social.approval_gate import (
approve_content, create_content, get_approval_stats,
get_content, list_contents, reject_content,
request_changes, schedule_content,
)
from common.social.post_queue import add_to_queue, assign_queue_slots
from common.social.scheduler import manual_publish
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/content", tags=["Content Approval"])
# ─── Request models ───────────────────────────────────────────────────────────
class CreateContentBody(BaseModel):
title: str
caption: str
platforms: list[str]
image_urls: list[str] = []
tags: list[str] = []
created_by: str = "ai_agent"
metadata: dict = {}
auto_submit: bool = False # Nếu True: tạo xong → submit ngay
class ReviewBody(BaseModel):
actor: str = "marketing_team"
comment: str = ""
class ScheduleBody(BaseModel):
scheduled_at: str
priority: bool = False
# ─── Endpoints ───────────────────────────────────────────────────────────────
@router.post("")
async def create_new_content(body: CreateContentBody):
"""Tạo content mới. Thường được AI stylist engine gọi."""
content = create_content(
title=body.title,
caption=body.caption,
platforms=body.platforms,
image_urls=body.image_urls,
created_by=body.created_by,
tags=body.tags,
metadata=body.metadata,
)
if body.auto_submit:
from common.social.approval_gate import submit_for_review
content = submit_for_review(content["id"], submitted_by=body.created_by)
return {"ok": True, "content": content}
@router.get("")
async def list_all_contents(
status: Optional[str] = None,
platform: Optional[str] = None,
limit: int = 50,
offset: int = 0,
):
"""List tất cả content với filter."""
return list_contents(status=status, platform=platform, limit=limit, offset=offset)
@router.get("/stats")
async def approval_stats():
"""Thống kê trạng thái content."""
return get_approval_stats()
@router.get("/{content_id}")
async def get_content_detail(content_id: str):
content = get_content(content_id)
if not content:
raise HTTPException(status_code=404, detail="Content not found")
return content
@router.post("/{content_id}/submit")
async def submit_content(content_id: str, body: ReviewBody):
from common.social.approval_gate import submit_for_review
try:
content = submit_for_review(content_id, submitted_by=body.actor)
return {"ok": True, "content": content}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{content_id}/approve")
async def approve(content_id: str, body: ReviewBody):
try:
content = approve_content(content_id, approved_by=body.actor, comment=body.comment)
# Tự động thêm vào queue
add_to_queue(content_id)
return {"ok": True, "content": content, "queued": True}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{content_id}/reject")
async def reject(content_id: str, body: ReviewBody):
if not body.comment:
raise HTTPException(status_code=422, detail="Phải có lý do khi từ chối.")
try:
content = reject_content(content_id, rejected_by=body.actor, comment=body.comment)
return {"ok": True, "content": content}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{content_id}/changes")
async def request_change(content_id: str, body: ReviewBody):
if not body.comment:
raise HTTPException(status_code=422, detail="Phải mô tả cần sửa gì.")
try:
content = request_changes(content_id, requested_by=body.actor, comment=body.comment)
return {"ok": True, "content": content}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{content_id}/schedule")
async def schedule(content_id: str, body: ScheduleBody):
try:
content = schedule_content(content_id, scheduled_at=body.scheduled_at)
add_to_queue(content_id, priority=body.priority)
return {"ok": True, "content": content}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{content_id}/publish-now")
async def publish_now(content_id: str):
"""Publish ngay lập tức — bypass queue timing."""
result = manual_publish(content_id)
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Publish failed"))
return {"ok": True, "result": result}
"""
Media Library REST API
POST /api/media/upload — upload ảnh/video
GET /api/media — list assets
GET /api/media/{id} — serve raw file
GET /api/media/{id}/thumb — serve thumbnail
GET /api/media/{id}/info — asset metadata
DELETE /api/media/{id} — xóa asset
GET /api/media/platforms — list platform sizes
POST /api/media/{id}/resize — resize theo platform
"""
import logging
import os
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from common.media.image_processor import (
PLATFORM_SIZES, delete_asset, get_asset,
list_assets, resize_for_platform, save_uploaded_asset,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/media", tags=["Media Library"])
ALLOWED_CONTENT_TYPES = {
"image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif",
"video/mp4", "video/quicktime", "video/webm",
}
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
@router.post("/upload")
async def upload_asset(
file: UploadFile = File(...),
tags: str = Query("", description="Comma-separated tags"),
uploaded_by: str = Query("user"),
):
"""Upload ảnh hoặc video vào media library."""
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=415,
detail=f"Unsupported file type: {file.content_type}. Allowed: {', '.join(ALLOWED_CONTENT_TYPES)}",
)
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(status_code=413, detail="File quá lớn (max 50MB)")
tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else []
asset = save_uploaded_asset(
filename=file.filename or "upload",
content_bytes=content,
content_type=file.content_type,
uploaded_by=uploaded_by,
tags=tag_list,
)
return {"ok": True, "asset": asset}
@router.get("")
async def list_media(
content_type: str = Query(None, description="Filter: 'image' or 'video'"),
tag: str = Query(None),
limit: int = 50,
offset: int = 0,
):
"""List tất cả assets trong media library."""
return list_assets(content_type=content_type, tag=tag, limit=limit, offset=offset)
@router.get("/platforms")
async def platform_sizes():
"""Danh sách platform sizes để resize."""
return {
"platforms": [
{"key": k, "width": v[0], "height": v[1]}
for k, v in PLATFORM_SIZES.items()
]
}
@router.get("/{asset_id}/info")
async def asset_info(asset_id: str):
"""Lấy metadata của asset."""
asset = get_asset(asset_id)
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
return asset
@router.get("/{asset_id}")
async def serve_asset(asset_id: str):
"""Serve file raw."""
asset = get_asset(asset_id)
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
path = asset.get("path", "")
if not os.path.exists(path):
raise HTTPException(status_code=404, detail="File not found on disk")
return FileResponse(path, media_type=asset.get("content_type", "application/octet-stream"))
@router.get("/{asset_id}/thumb")
async def serve_thumbnail(asset_id: str):
"""Serve thumbnail image."""
asset = get_asset(asset_id)
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
thumb_path = asset.get("thumb_path", "")
if not thumb_path or not os.path.exists(thumb_path):
# Fallback to original
return await serve_asset(asset_id)
return FileResponse(thumb_path, media_type="image/jpeg")
@router.post("/{asset_id}/resize")
async def resize_asset(asset_id: str, platform: str):
"""Resize asset theo platform spec. Returns resized image bytes."""
from fastapi.responses import Response
asset = get_asset(asset_id)
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
if platform not in PLATFORM_SIZES:
raise HTTPException(
status_code=400,
detail=f"Unknown platform: {platform}. Available: {list(PLATFORM_SIZES.keys())}",
)
path = asset.get("path", "")
if not os.path.exists(path):
raise HTTPException(status_code=404, detail="File not found on disk")
with open(path, "rb") as f:
content_bytes = f.read()
resized = resize_for_platform(content_bytes, platform)
size = PLATFORM_SIZES[platform]
return Response(
content=resized,
media_type="image/jpeg",
headers={
"Content-Disposition": f'inline; filename="{asset_id}_{platform}.jpg"',
"X-Dimensions": f"{size[0]}x{size[1]}",
},
)
@router.delete("/{asset_id}")
async def delete_media_asset(asset_id: str):
"""Xóa asset khỏi library."""
ok = delete_asset(asset_id)
if not ok:
raise HTTPException(status_code=404, detail="Asset not found")
return {"ok": True}
"""
Notification REST API Route
GET /api/notifications — lấy danh sách (in-app bell)
PATCH /api/notifications/{id}/read — đánh dấu đã đọc
POST /api/notifications/read-all — đánh dấu tất cả đã đọc
GET /api/notifications/unread-count — số chưa đọc (badge)
POST /api/notifications/test — test fire một notification
"""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from common.notification.engine import (
get_notifications, get_unread_count,
mark_all_read, mark_read, notify, check_negative_spike,
)
from common.notification.events import EventType
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/notifications", tags=["Notifications"])
@router.get("")
async def list_notifications(
limit: int = 50,
unread_only: bool = False,
):
"""Lấy danh sách notifications cho in-app bell."""
notifs = get_notifications(limit=limit, unread_only=unread_only)
return {
"notifications": notifs,
"total": len(notifs),
"unread_count": get_unread_count(),
}
@router.get("/unread-count")
async def unread_count():
"""Số notification chưa đọc — dùng cho badge."""
return {"unread_count": get_unread_count()}
@router.patch("/{notification_id}/read")
async def mark_notification_read(notification_id: str):
"""Đánh dấu một notification đã đọc."""
success = mark_read(notification_id)
if not success:
raise HTTPException(status_code=404, detail="Notification not found")
return {"ok": True, "unread_count": get_unread_count()}
@router.post("/read-all")
async def mark_all_notifications_read():
"""Đánh dấu tất cả notifications đã đọc."""
count = mark_all_read()
return {"ok": True, "marked_read": count}
@router.post("/test")
async def fire_test_notification(body: dict):
"""
Fire một test notification — dùng khi debug/demo.
Body: { "event_type": "negative_spike", "title": "...", "body": "..." }
"""
event_type = body.get("event_type", EventType.NEW_SOCIAL_MESSAGE)
title = body.get("title", "Test Notification")
msg_body = body.get("body", "Đây là test notification từ Canifa AI Dashboard.")
notif = notify(event_type=event_type, title=title, body=msg_body)
return {"ok": True, "notification": notif}
@router.post("/check-spike")
async def check_spike():
"""Manually trigger negative spike check."""
spiked = check_negative_spike()
return {"spiked": spiked, "unread_count": get_unread_count()}
"""
Content Queue & Calendar REST API
GET /api/queue — xem queue hiện tại
POST /api/queue/{content_id} — thêm vào queue
DELETE /api/queue/{content_id} — xóa khỏi queue
POST /api/queue/reorder — drag-drop reorder
GET /api/queue/calendar — calendar view (theo tuần)
GET /api/queue/slots — xem posting slots
PUT /api/queue/slots — update slots
POST /api/queue/slots/reset — reset về default
GET /api/queue/retry-state — trạng thái retry publisher
POST /api/queue/publish-tick — manual trigger publish engine tick
"""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from common.social.post_queue import (
add_to_queue, get_calendar_week, get_queue,
get_slots, remove_from_queue, reorder_queue,
reset_slots_to_default, save_slots,
)
from common.social.scheduler import get_retry_state, _engine
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/queue", tags=["Content Queue"])
class ReorderBody(BaseModel):
ordered_content_ids: list[str]
class AddToQueueBody(BaseModel):
priority: bool = False
@router.get("")
async def get_current_queue():
"""Lấy queue hiện tại kèm content data."""
queue = get_queue()
return {"queue": queue, "total": len(queue)}
@router.post("/{content_id}")
async def add_content_to_queue(content_id: str, body: AddToQueueBody = AddToQueueBody()):
"""Thêm content vào queue."""
queue = add_to_queue(content_id, priority=body.priority)
return {"ok": True, "queue": queue}
@router.delete("/{content_id}")
async def remove_content_from_queue(content_id: str):
"""Xóa content khỏi queue."""
queue = remove_from_queue(content_id)
return {"ok": True, "queue": queue}
@router.post("/reorder")
async def reorder(body: ReorderBody):
"""Drag-drop reorder — pass danh sách content_id theo thứ tự mới."""
queue = reorder_queue(body.ordered_content_ids)
return {"ok": True, "queue": queue}
@router.get("/calendar")
async def calendar_week(week_offset: int = 0):
"""Calendar view theo tuần. week_offset=0 → tuần này."""
return get_calendar_week(week_offset=week_offset)
@router.get("/slots")
async def get_posting_slots():
"""Lấy posting slots hiện tại."""
return {"slots": get_slots()}
@router.put("/slots")
async def update_slots(slots: list[dict]):
"""Cập nhật posting slots. Input: [{day, hour, minute, label}]"""
saved = save_slots(slots)
return {"ok": True, "slots": saved}
@router.post("/slots/reset")
async def reset_slots():
"""Reset posting slots về default của BrightBean."""
slots = reset_slots_to_default()
return {"ok": True, "slots": slots}
@router.get("/retry-state")
async def retry_state():
"""Xem trạng thái retry của publisher engine."""
return {"retry_state": get_retry_state()}
@router.post("/publish-tick")
async def manual_publish_tick():
"""Manual trigger một publish engine tick (dùng khi debug/testing)."""
import asyncio
loop = asyncio.get_event_loop()
count = await loop.run_in_executor(None, _engine.poll_and_publish)
return {"ok": True, "published_count": count}
"""
Social Inbox API Route
Nhận webhook từ Facebook/Instagram/TikTok → phân tích sentiment → feed vào Canifa learning loop.
Cũng expose REST API để FE dashboard đọc messages.
"""
import json
import logging
import os
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Query, Request
from fastapi.responses import JSONResponse, PlainTextResponse
from common.social.inbox_webhook import (
handle_facebook_verify,
parse_facebook_webhook,
parse_tiktok_webhook,
verify_facebook_signature,
)
from common.social.sentiment_vi import analyze_sentiment_detail
from common.feedback_tracker import save_feedback
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/social", tags=["Social Inbox"])
# File lưu social messages (lightweight, tương tự feedback_tracker)
SOCIAL_MESSAGES_FILE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "data", "social_messages.json"
)
# ─────────────────────────────────────────────
# Storage helpers
# ─────────────────────────────────────────────
def _ensure_dir():
os.makedirs(os.path.dirname(SOCIAL_MESSAGES_FILE), exist_ok=True)
def _load_messages() -> list[dict]:
_ensure_dir()
if not os.path.exists(SOCIAL_MESSAGES_FILE):
return []
try:
with open(SOCIAL_MESSAGES_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return []
def _save_messages(messages: list[dict]):
_ensure_dir()
with open(SOCIAL_MESSAGES_FILE, "w", encoding="utf-8") as f:
json.dump(messages, f, ensure_ascii=False, indent=2, default=str)
def _append_messages(new_messages: list[dict]):
"""Append new messages, dedup by platform + body."""
existing = _load_messages()
existing_keys = {(m.get("platform"), m.get("body")) for m in existing}
added = 0
for msg in new_messages:
key = (msg.get("platform"), msg.get("body"))
if key not in existing_keys:
existing.insert(0, msg) # newest first
existing_keys.add(key)
added += 1
# Giữ tối đa 500 messages
_save_messages(existing[:500])
return added
# ─────────────────────────────────────────────
# Background: Feed message vào learning loop
# ─────────────────────────────────────────────
def _feed_to_feedback_tracker(messages: list[dict]):
"""
Feed social messages vào Canifa feedback_tracker.
Negative messages → learning loop để AI cải thiện.
"""
for msg in messages:
if msg.get("sentiment") == "negative":
try:
save_feedback(
trace_id=f"social_{msg.get('platform')}_{datetime.now(timezone.utc).timestamp()}",
rating=0, # 0 = dislike (negative feedback)
comment=msg.get("body", ""),
user_category="social_feedback",
ai_category="social_negative_comment",
ai_severity="low",
ai_summary=f"[{msg.get('platform', '').upper()}] Negative comment từ @{msg.get('sender_name', 'Unknown')}: {msg.get('body', '')[:100]}",
)
logger.info(f"📥 Social negative feedback fed to learning loop: {msg.get('platform')} / {msg.get('body', '')[:50]}")
except Exception as e:
logger.error(f"Failed to feed to feedback tracker: {e}")
# ─────────────────────────────────────────────
# Facebook/Instagram Webhook
# ─────────────────────────────────────────────
@router.get("/webhook/facebook")
async def facebook_webhook_verify(
hub_mode: str = Query(None, alias="hub.mode"),
hub_verify_token: str = Query(None, alias="hub.verify_token"),
hub_challenge: str = Query("", alias="hub.challenge"),
):
"""Facebook webhook verification (GET handshake)."""
challenge = handle_facebook_verify(hub_mode or "", hub_verify_token or "", hub_challenge)
if challenge is None:
raise HTTPException(status_code=403, detail="Webhook verification failed")
return PlainTextResponse(challenge)
@router.post("/webhook/facebook")
async def facebook_webhook_receive(
request: Request,
background_tasks: BackgroundTasks,
x_hub_signature_256: Optional[str] = Header(None, alias="X-Hub-Signature-256"),
):
"""Nhận Facebook/Instagram comment/DM webhook events."""
body = await request.body()
# Verify signature
if not verify_facebook_signature(body, x_hub_signature_256 or ""):
raise HTTPException(status_code=403, detail="Invalid webhook signature")
try:
payload = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON")
messages = parse_facebook_webhook(payload)
if messages:
added = _append_messages(messages)
background_tasks.add_task(_feed_to_feedback_tracker, messages)
logger.info(f"📬 Facebook/IG webhook: {len(messages)} events, {added} mới")
return JSONResponse({"ok": True, "received": len(messages)})
# ─────────────────────────────────────────────
# TikTok Webhook
# ─────────────────────────────────────────────
@router.post("/webhook/tiktok")
async def tiktok_webhook_receive(
request: Request,
background_tasks: BackgroundTasks,
):
"""Nhận TikTok comment webhook events."""
try:
payload = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON")
messages = parse_tiktok_webhook(payload)
if messages:
added = _append_messages(messages)
background_tasks.add_task(_feed_to_feedback_tracker, messages)
logger.info(f"📬 TikTok webhook: {len(messages)} comments, {added} mới")
return JSONResponse({"ok": True, "received": len(messages)})
# ─────────────────────────────────────────────
# REST API cho FE Dashboard
# ─────────────────────────────────────────────
@router.get("/messages")
async def get_social_messages(
platform: Optional[str] = Query(None, description="facebook | instagram | tiktok"),
sentiment: Optional[str] = Query(None, description="positive | negative | neutral"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
"""
Lấy danh sách social messages cho dashboard.
Filter theo platform và/hoặc sentiment.
"""
messages = _load_messages()
if platform:
messages = [m for m in messages if m.get("platform") == platform]
if sentiment:
messages = [m for m in messages if m.get("sentiment") == sentiment]
total = len(messages)
paginated = messages[offset: offset + limit]
return {
"total": total,
"limit": limit,
"offset": offset,
"messages": paginated,
}
@router.get("/messages/stats")
async def get_social_stats():
"""Thống kê tổng hợp social messages theo platform + sentiment."""
messages = _load_messages()
total = len(messages)
stats: dict = {
"total": total,
"by_platform": {},
"by_sentiment": {"positive": 0, "negative": 0, "neutral": 0},
"negative_rate": 0.0,
}
for msg in messages:
platform = msg.get("platform", "unknown")
sentiment = msg.get("sentiment", "neutral")
if platform not in stats["by_platform"]:
stats["by_platform"][platform] = {"total": 0, "positive": 0, "negative": 0, "neutral": 0}
stats["by_platform"][platform]["total"] += 1
stats["by_platform"][platform][sentiment] = stats["by_platform"][platform].get(sentiment, 0) + 1
stats["by_sentiment"][sentiment] = stats["by_sentiment"].get(sentiment, 0) + 1
if total > 0:
stats["negative_rate"] = round(stats["by_sentiment"]["negative"] / total * 100, 1)
return stats
@router.post("/messages/analyze")
async def analyze_text_sentiment(request: Request):
"""
Test sentiment analysis — nhập text, trả về phân tích.
Dùng cho debug / demo trên dashboard.
"""
body = await request.json()
text = body.get("text", "")
if not text:
raise HTTPException(status_code=400, detail="text is required")
result = analyze_sentiment_detail(text)
return result
@router.delete("/messages/clear")
async def clear_social_messages():
"""Xóa toàn bộ messages (dùng khi debug)."""
_save_messages([])
return {"ok": True, "message": "Đã xóa tất cả social messages"}
"""
API route: Content Templates + RSS Feeds
Ported từ BrightBean apps/composer — cung cấp 30+ content templates và RSS feed catalog
"""
from fastapi import APIRouter, Query
from common.content_templates import (
get_templates, get_template, get_categories, search_templates,
get_feeds, get_feed_categories,
)
router = APIRouter(prefix="/api/templates", tags=["templates"])
@router.get("")
def list_templates(category: str = "all", limit: int = 50):
"""Liệt kê content templates theo category."""
return {
"templates": get_templates(category=category, limit=limit),
"categories": get_categories(),
}
@router.get("/categories")
def list_categories():
return {"categories": get_categories()}
@router.get("/search")
def search(q: str = Query(..., min_length=2)):
return {"templates": search_templates(q)}
@router.get("/{template_id}")
def get_one(template_id: int):
t = get_template(template_id)
if not t:
from fastapi import HTTPException
raise HTTPException(404, "Template not found")
return t
@router.get("/feeds/categories")
def feed_categories():
return {"categories": get_feed_categories()}
@router.get("/feeds/{category}")
def feeds_for_category(category: str):
return {"feeds": get_feeds(category)}
"""
Fashion Content Templates — Canifa Edition
Ported & adapted từ BrightBean Studio apps/composer/builtin_templates.py (968 lines, 161 templates)
Đã customize cho fashion/e-commerce vertical + thêm template tiếng Việt cho Canifa
"""
# Six rotating accent colors
COLORS = [
"255, 203, 49", # yellow
"218, 255, 53", # lime
"99, 117, 255", # indigo
"248, 149, 151", # pink
"87, 152, 255", # blue
"59, 252, 255", # cyan
]
CATEGORIES = [
{"slug": "all", "label": "Tất cả", "icon": "layout-grid"},
{"slug": "outfit", "label": "Outfit Ideas", "icon": "shirt"},
{"slug": "product", "label": "Sản phẩm", "icon": "shopping-bag"},
{"slug": "seasonal", "label": "Theo mùa", "icon": "sun"},
{"slug": "style-tip", "label": "Tip Phong cách", "icon": "lightbulb"},
{"slug": "behind-scenes", "label": "Hậu trường", "icon": "clapperboard"},
{"slug": "engagement", "label": "Tương tác", "icon": "message-circle"},
{"slug": "promotion", "label": "Khuyến mãi", "icon": "tag"},
{"slug": "story", "label": "Câu chuyện", "icon": "book-open"},
]
FEATURED_IDS = [1, 10, 20, 31, 40]
TEMPLATES = [
# ─── OUTFIT IDEAS ─────────────────────────────────────────────────────────
{
"id": 1,
"emoji": "👗",
"title": "Gợi ý phối đồ theo dáng người",
"description": "Hướng dẫn phối đồ tôn dáng cho từng vóc người — nội dung giá trị giúp tăng engagement cao.",
"body": "{{Dáng người: hình quả lê / đồng hồ cát / chữ H / táo}} thì nên mặc gì?\n\n✅ NÊN chọn:\n• {{item 1 tôn dáng}}\n• {{item 2 tôn dáng}}\n• {{item 3 tôn dáng}}\n\n❌ Nên tránh:\n• {{item không phù hợp 1}}\n• {{item không phù hợp 2}}\n\nCanifa gợi ý cho bạn: {{tên sản phẩm}} — link in bio 👆",
"tags": ["Outfit", "Style Tips", "Body Positive"],
"category": "outfit",
"color": COLORS[0],
},
{
"id": 2,
"emoji": "🌈",
"title": "Phối màu cơ bản không bao giờ sai",
"description": "Hướng dẫn phối màu an toàn — content evergreen luôn có lượt lưu cao.",
"body": "Quy tắc phối màu {{màu chủ đạo}} mà ai cũng nên biết:\n\n🎨 Màu trung tính kết hợp tốt với:\n{{màu 1}} · {{màu 2}} · {{màu 3}}\n\n💡 Tip Canifa:\nSet {{tên outfit}} của chúng mình đang áp dụng đúng nguyên tắc này — đẹp mà không cần nghĩ nhiều!\n\n#PhốiMàu #StyleTips #CanifaStyle",
"tags": ["Color Theory", "Style Tips"],
"category": "outfit",
"color": COLORS[1],
},
{
"id": 3,
"emoji": "👔",
"title": "1 item mặc được 3 kiểu",
"description": "Prove versatility của sản phẩm — nội dung cao giá trị, khuyến khích purchase.",
"body": "{{Tên sản phẩm}} — mặc được {{N}} cách khác nhau!\n\nCách 1 — {{dịp/phong cách}}: {{mô tả outfit}}\nCách 2 — {{dịp/phong cách}}: {{mô tả outfit}}\nCách 3 — {{dịp/phong cách}}: {{mô tả outfit}}\n\nMột item, nhiều lần mặc. Đó mới là mua thông minh 💡\n\nShop ngay: {{link}} hoặc tìm tại cửa hàng Canifa gần bạn nhất.",
"tags": ["Versatile", "Smart Shopping"],
"category": "outfit",
"color": COLORS[2],
},
{
"id": 4,
"emoji": "🗓️",
"title": "Outfit theo ngày trong tuần",
"description": "Series nội dung liên tục theo tuần — dễ tạo, engagement ổn định.",
"body": "{{Thứ}} outfit của chúng mình 📅\n\nHôm nay {{thời tiết/sự kiện}}, set đồ Canifa:\n• Top: {{tên sản phẩm + màu sắc}}\n• Bottom: {{tên sản phẩm + màu sắc}}\n• Phụ kiện: {{phụ kiện nếu có}}\n\nTổng giá trị set: {{giá}} — Canifa price, real style ✨\n\nBạn thường mặc gì vào {{thứ}}? Comment bên dưới nhé!",
"tags": ["Weekly Outfit", "Daily Style"],
"category": "outfit",
"color": COLORS[3],
},
{
"id": 5,
"emoji": "⚖️",
"title": "Casual vs Formal — Cùng 1 item",
"description": "So sánh trực quan — format before/after luôn viral tốt.",
"body": "{{Tên sản phẩm}} mặc casual vs formal — ai ngờ cùng 1 item!\n\n👕 CASUAL:\n{{mô tả phối casual + ảnh trái}}\n\n👔 FORMAL:\n{{mô tả phối formal + ảnh phải}}\n\nBí quyết? {{tip styling key}}\n\nCanifa {{tên sản phẩm}} hiện đang có tại: {{link/cửa hàng}}",
"tags": ["Before After", "Styling"],
"category": "outfit",
"color": COLORS[4],
},
# ─── PRODUCT SHOWCASE ─────────────────────────────────────────────────────
{
"id": 10,
"emoji": "✨",
"title": "Ra mắt sản phẩm mới",
"description": "Template announce product launch — tạo hype và FOMO.",
"body": "🆕 JUST DROPPED: {{Tên sản phẩm}}\n\nSau {{thời gian}} nghiên cứu & phát triển, chúng mình tự hào giới thiệu {{mô tả ngắn}}.\n\nĐiểm highlight:\n🔹 {{feature 1}}\n🔹 {{feature 2}}\n🔹 {{feature 3}}\n\nHave tại: {{màu sắc/size available}}\nGiá: {{giá}}\n\nSẵn sàng có mặt từ {{ngày}} — link in bio để đặt trước!",
"tags": ["New Arrival", "Product Launch"],
"category": "product",
"color": COLORS[5],
},
{
"id": 11,
"emoji": "🌟",
"title": "Spotlight sản phẩm bán chạy",
"description": "Social proof qua bestseller — proof point mạnh cho conversion.",
"body": "Tại sao {{Tên sản phẩm}} là bestseller của Canifa?\n\nKhách hàng yêu thích vì:\n⭐ {{lý do 1}}\n⭐ {{lý do 2}}\n⭐ {{lý do 3}}\n\n📊 Đã có {{số lượng}} khách hàng chọn mua trong {{khoảng thời gian}}\n\nBạn chưa thử? Còn {{số lượng}} size {{size}} — nhanh kéo link bio!",
"tags": ["Bestseller", "Social Proof"],
"category": "product",
"color": COLORS[0],
},
{
"id": 12,
"emoji": "🔍",
"title": "Detail material & chất liệu",
"description": "Giáo dục khách hàng về chất liệu — build trust cho brand.",
"body": "Bạn có biết {{Tên sản phẩm}} làm từ chất liệu gì không?\n\n🧵 CHẤT LIỆU: {{tên chất liệu}}\n\nTại sao Canifa chọn chất liệu này?\n✅ {{lợi ích 1}}\n✅ {{lợi ích 2}}\n✅ {{lợi ích 3}}\n\nPhù hợp với thời tiết {{mùa}} Việt Nam — {{lý do cụ thể}}\n\n#ChấtLiệuViệt #CanifaQuality",
"tags": ["Education", "Quality"],
"category": "product",
"color": COLORS[1],
},
{
"id": 13,
"emoji": "📐",
"title": "Hướng dẫn chọn size",
"description": "Size guide — giảm tỷ lệ đổi trả, tăng customer satisfaction.",
"body": "Chọn size {{Tên sản phẩm}} thế nào cho đúng?\n\n📏 BẢNG SIZE CANIFA:\n| Size | Chiều cao | Cân nặng |\n| {{S}} | {{h-w range}} | {{range}} |\n| {{M}} | {{h-w range}} | {{range}} |\n| {{L}} | {{h-w range}} | {{range}} |\n\n💡 Tip từ Canifa:\n{{lời khuyên chọn size dựa trên dáng người}}\n\nVẫn không chắc? DM chúng mình với số đo của bạn, team sẽ tư vấn free!",
"tags": ["Size Guide", "Customer Service"],
"category": "product",
"color": COLORS[2],
},
# ─── SEASONAL ─────────────────────────────────────────────────────────────
{
"id": 20,
"emoji": "☀️",
"title": "Xu hướng fashion mùa hè",
"description": "Seasonal trend report — content luôn viral trước mỗi mùa.",
"body": "{{Năm}} Summer Trends đã đến! 🌊\n\nCanifa dự đoán {{N}} xu hướng hot nhất mùa hè năm nay:\n\n1️⃣ {{trend 1}} — {{mô tả + lý do}}\n2️⃣ {{trend 2}} — {{mô tả + lý do}}\n3️⃣ {{trend 3}} — {{mô tả + lý do}}\n4️⃣ {{trend 4}} — {{mô tả + lý do}}\n\nBạn đang follow trend nào? Comment 1/2/3/4 nhé!\n\n#SummerFashion #TrendAlert #CanifaStyle",
"tags": ["Trend", "Seasonal"],
"category": "seasonal",
"color": COLORS[3],
},
{
"id": 21,
"emoji": "🍂",
"title": "Tủ đồ mùa thu cần gì",
"description": "Wardrobe guide cho mùa — tăng cart value qua cross-sell.",
"body": "Mùa thu về, tủ đồ của bạn cần bổ sung gì? 🍂\n\nCanifa checklist mùa thu {{năm}}:\n\n☐ {{item 1}} — lý do cần thiết\n☐ {{item 2}} — lý do cần thiết\n☐ {{item 3}} — lý do cần thiết\n☐ {{item 4}} — lý do cần thiết\n☐ {{item 5}} — lý do cần thiết\n\nCanifa có đủ hết — và giá không bao giờ làm bạn shock 😊\nXem collection mùa thu: {{link}}",
"tags": ["Seasonal", "Checklist"],
"category": "seasonal",
"color": COLORS[4],
},
{
"id": 22,
"emoji": "🎄",
"title": "Outfit Tết / Giáng sinh",
"description": "Holiday content — peak season, engagement và sales cao nhất năm.",
"body": "{{Tết/Giáng sinh}} sắp đến rồi! Bạn đã có outfit chưa? 🎉\n\nCanifa gợi ý cho bạn:\n\n🔴 Cho không khí {{lễ}}: {{set đồ 1}}\n🟡 Đi chơi với gia đình: {{set đồ 2}}\n🟢 Gặp gỡ bạn bè: {{set đồ 3}}\n\nShip trong {{N}} ngày, order ngay để kịp mặc dịp {{lễ}}!\n\nLink order: {{link}} hoặc đến store Canifa gần nhất 🛍️",
"tags": ["Holiday", "Seasonal", "Festive"],
"category": "seasonal",
"color": COLORS[5],
},
# ─── STYLE TIPS ───────────────────────────────────────────────────────────
{
"id": 30,
"emoji": "💡",
"title": "5 lỗi phối đồ phổ biến cần tránh",
"description": "Negative tips (lỗi cần tránh) — engagement cao hơn tips thông thường.",
"body": "{{N}} lỗi phối đồ mà hầu hết mọi người mắc phải (bạn có không?) 👇\n\n❌ Lỗi 1: {{mô tả lỗi}} → Cách sửa: {{giải pháp}}\n❌ Lỗi 2: {{mô tả lỗi}} → Cách sửa: {{giải pháp}}\n❌ Lỗi 3: {{mô tả lỗi}} → Cách sửa: {{giải pháp}}\n❌ Lỗi 4: {{mô tả lỗi}} → Cách sửa: {{giải pháp}}\n❌ Lỗi 5: {{mô tả lỗi}} → Cách sửa: {{giải pháp}}\n\nBạn hay mắc lỗi số mấy nhất? 😄\n\n#StyleMistakes #FashionTips #CanifaStyle",
"tags": ["Tips", "Education", "Engagement"],
"category": "style-tip",
"color": COLORS[0],
},
{
"id": 31,
"emoji": "🎒",
"title": "Capsule wardrobe tối giản",
"description": "Minimalist wardrobe content — resonates với Gen Z và Millennials.",
"body": "Chỉ cần {{N}} items để có tủ đồ đủ mặc cả năm 🧳\n\nCanifa Capsule Wardrobe {{năm}}:\n\n🔹 Basics ({{N}} items): {{danh sách}}\n🔹 Statement pieces ({{N}} items): {{danh sách}}\n🔹 Outerwear ({{N}} items): {{danh sách}}\n🔹 Phụ kiện ({{N}} items): {{danh sách}}\n\nTổng chi phí: ~{{giá}} — đủ mặc {{N}} tháng mà không cần mua thêm.\n\nCanifa có đủ hết các items này — check link bio!",
"tags": ["Capsule Wardrobe", "Minimalist", "Sustainable"],
"category": "style-tip",
"color": COLORS[1],
},
{
"id": 32,
"emoji": "👗",
"title": "Cách chọn đồ cho từng dịp",
"description": "Dress code guide — problem-solution format, high save rate.",
"body": "Không biết mặc gì cho {{dịp}}? Đây là guide của Canifa:\n\n📋 {{DỊP}} DRESS CODE:\n\n✅ Phù hợp:\n• {{item 1}}\n• {{item 2}}\n• {{item 3}}\n\n❌ Không phù hợp:\n• {{item A}}\n• {{item B}}\n\n💡 Canifa tip: {{lời khuyên styling đặc biệt cho dịp này}}\n\nSave lại để dùng khi cần nhé! 📌",
"tags": ["Dress Code", "Tips"],
"category": "style-tip",
"color": COLORS[2],
},
# ─── BEHIND THE SCENES ────────────────────────────────────────────────────
{
"id": 40,
"emoji": "🏭",
"title": "Hậu trường thiết kế sản phẩm",
"description": "BTS content tạo brand trust và emotional connection với khách hàng.",
"body": "Bạn có bao giờ thắc mắc {{Tên sản phẩm}} được tạo ra như thế nào không? 🧵\n\nHành trình từ {{nguyên liệu}} tới tay bạn:\n\n1. {{bước 1}} — {{mô tả + thời gian}}\n2. {{bước 2}} — {{mô tả + điều thú vị}}\n3. {{bước 3}} — {{quality check}}\n4. {{bước cuối}} — sẵn sàng tới tay bạn!\n\nMỗi sản phẩm Canifa là {{số}} giờ công sức của đội ngũ người thợ Việt Nam 🇻🇳\n\n#MadeInVietnam #Canifa #BehindTheScenes",
"tags": ["BTS", "Brand Story", "Manufacturing"],
"category": "behind-scenes",
"color": COLORS[3],
},
{
"id": 41,
"emoji": "👩‍🎨",
"title": "Gặp gỡ đội ngũ thiết kế",
"description": "Team spotlight — humanizes brand, tăng follower loyalty.",
"body": "Gặp gỡ {{Tên}} — {{vai trò}} tại Canifa 👋\n\n{{Tên}} đã gắn bó với Canifa được {{N}} năm và đứng sau những thiết kế {{mô tả}}\n\n💬 \"{{quote của nhân vật về công việc/passion}}\"\n\nDesign philosophy của {{Tên/team}}: {{triết lý thiết kế}}\n\nBộ sưu tập được yêu thích nhất: {{BST}}\n\nTag người bạn muốn làm việc tại Canifa! 👇\n#CanifaTeam #FashionDesign #MadeWithLove",
"tags": ["Team Spotlight", "Brand Human"],
"category": "behind-scenes",
"color": COLORS[4],
},
{
"id": 42,
"emoji": "📸",
"title": "Hậu trường buổi chụp ảnh",
"description": "Photoshoot BTS — relatable, shows brand personality và authentic side.",
"body": "Hậu trường buổi chụp ảnh {{tên bộ sưu tập}} 📸\n\nHôm nay mình có:\n• Location: {{địa điểm}}\n• Đội ngũ: {{N}} người\n• Số set trang phục: {{N}}\n• Thời gian: {{N}} tiếng\n\nMoment yêu thích nhất hôm nay: {{khoảnh khắc thú vị/bất ngờ}}\n\nCampaign chính thức sẽ ra mắt vào {{ngày}} — bạn có hứng khởi không? 🔥\n\n#CanifaBTS #PhotoShoot #ComingSoon",
"tags": ["Photoshoot BTS", "Authentic"],
"category": "behind-scenes",
"color": COLORS[5],
},
# ─── ENGAGEMENT ───────────────────────────────────────────────────────────
{
"id": 50,
"emoji": "❓",
"title": "This or That — Phong cách",
"description": "This or That format — comment engagement rate cực cao, algorithm friendly.",
"body": "This or That? Phong cách của bạn thiên về bên nào?\n\n{{OPTION A}} 👕\nvs\n{{OPTION B}} 👗\n\nComment \"A\" hoặc \"B\" bên dưới nhé! Canifa muốn hiểu style của bạn hơn 👇\n\nP.S: Canifa có cả hai — link in bio!\n\n#ThisOrThat #StylePoll #CanifaCommunity",
"tags": ["Poll", "Engagement", "Community"],
"category": "engagement",
"color": COLORS[0],
},
{
"id": 51,
"emoji": "💬",
"title": "Caption contest / Điền vào chỗ trống",
"description": "Fill-in-the-blank — low effort để comment, high engagement rate.",
"body": "Điền vào chỗ trống:\n\n\"Sáng nay tôi mở tủ ra, nhìn cả tủ đầy đồ mà vẫn thấy... ___\"\n\n😂 Tag người bạn cũng hay bị tình trạng này nhé!\n\nGợi ý từ Canifa: Đó là lúc bạn cần thêm {{tên sản phẩm}} vào bộ sưu tập của mình 😄\n\n#FashionProblems #CanifaFam",
"tags": ["Fill the blank", "Humor", "Engagement"],
"category": "engagement",
"color": COLORS[1],
},
{
"id": 52,
"emoji": "🌟",
"title": "Customer review spotlight",
"description": "Real review từ khách hàng — social proof mạnh nhất.",
"body": "⭐⭐⭐⭐⭐ Review từ khách hàng của Canifa:\n\n\"{{quote review thực của khách hàng}}\"\n— {{Tên khách hàng}} (@{{handle}})\n\n{{Khách hàng}} đang mặc {{Tên sản phẩm}} size {{S/M/L}} màu {{màu}}\n\nBạn có trải nghiệm tương tự? Chia sẻ với chúng mình nhé!\n📩 DM hoặc tag #CanifaStyle\n\n#CustomerReview #Canifa #RealCustomer",
"tags": ["Social Proof", "Review", "UGC"],
"category": "engagement",
"color": COLORS[2],
},
# ─── PROMOTION ────────────────────────────────────────────────────────────
{
"id": 60,
"emoji": "🔥",
"title": "Flash sale alert",
"description": "FOMO-driven flash sale — urgency + benefit = conversions cao.",
"body": "⚡ FLASH SALE — CHỈ {{N}} GIỜ!\n\nGiảm {{%}} toàn bộ {{danh mục}} tại Canifa!\n\n🔖 Không cần mã giảm giá — giảm tự động khi checkout\n\n⏳ Kết thúc lúc: {{giờ}} hôm nay ({{ngày}})\n\nMua ngay trước khi hết: {{link}}\nHoặc đến store Canifa gần bạn nhất!\n\n#FlashSale #Canifa #LimitedTime",
"tags": ["Flash Sale", "Promotion", "FOMO"],
"category": "promotion",
"color": COLORS[3],
},
{
"id": 61,
"emoji": "🎁",
"title": "Combo deal — Mua nhiều lãi nhiều",
"description": "Bundle deals — tăng AOV (Average Order Value).",
"body": "Mua càng nhiều — TIẾT KIỆM càng nhiều! 🛍️\n\nCanifa Combo Deal:\n\n📦 COMBO {{A}}: {{N}} items → Giá: {{giá gốc}} → Chỉ còn {{giá sale}} (-{{%}})\n📦 COMBO {{B}}: {{N}} items → Giá: {{giá gốc}} → Chỉ còn {{giá sale}} (-{{%}})\n📦 COMBO {{C}}: {{N}} items → Giá: {{giá gốc}} → Chỉ còn {{giá sale}} (-{{%}})\n\n💳 Áp dụng đến: {{ngày hết hạn}}\n\nOrder ngay: {{link}} — Ship toàn quốc!",
"tags": ["Bundle", "Promotion", "Value"],
"category": "promotion",
"color": COLORS[4],
},
# ─── BRAND STORY ──────────────────────────────────────────────────────────
{
"id": 70,
"emoji": "🇻🇳",
"title": "Câu chuyện thương hiệu Canifa",
"description": "Brand origin story — emotional connection, loyalty building.",
"body": "{{Năm}} — Canifa bắt đầu từ một câu hỏi đơn giản:\n\n\"Tại sao người Việt phải dùng đồ ngoại nhập khi ta hoàn toàn có thể tự làm tốt hơn?\"\n\nTừ {{N}} cửa hàng đầu tiên ở {{địa điểm}}, đến hôm nay với {{N}} cơ sở trên cả nước.\n\nKhông thay đổi là gì? {{giá trị cốt lõi}}.\n\n{{N}} năm — {{N}} triệu khách hàng tin tưởng — cảm ơn bạn đã là một phần của hành trình này 🙏\n\n#Canifa25Years #MadeInVietnam #VietnameseBrand",
"tags": ["Brand Story", "Heritage", "Milestone"],
"category": "story",
"color": COLORS[5],
},
{
"id": 71,
"emoji": "♻️",
"title": "Canifa & Bền vững",
"description": "Sustainability content — resonates mạnh với Gen Z & Millennials.",
"body": "Canifa cam kết với thời trang bền vững 🌱\n\nNăm {{năm}}, chúng mình đã:\n✅ Sử dụng {{%}} vải tái chế trong sản xuất\n✅ Giảm {{%}} lượng nước tiêu thụ\n✅ Chuyển sang bao bì {{loại bao bì}}\n✅ {{achievement khác}}\n\nMục tiêu {{năm tới}}: {{cam kết tương lai}}\n\nFashion đẹp không cần phải đánh đổi Trái Đất 💚\n\n#SustainableFashion #CanifaGreen #FashionForFuture",
"tags": ["Sustainability", "CSR", "Green"],
"category": "story",
"color": COLORS[0],
},
]
FASHION_RSS_FEEDS = {
"thoi-trang-viet-nam": [
{"name": "Elle Vietnam", "website": "https://elle.vn/", "rss": "https://elle.vn/feed"},
{"name": "Vogue VN", "website": "https://vogue.vn/", "rss": "https://vogue.vn/feed"},
{"name": "Harper's Bazaar VN", "website": "https://harpersbazaar.vn/", "rss": "https://harpersbazaar.vn/feed"},
{"name": "Đẹp Magazine", "website": "https://dep.com.vn/", "rss": "https://dep.com.vn/feed"},
],
"fashion-global": [
{"name": "Vogue", "website": "https://vogue.com/", "rss": "https://www.vogue.com/feed/rss"},
{"name": "WWD", "website": "https://wwd.com/", "rss": "https://wwd.com/feed/"},
{"name": "Business of Fashion", "website": "https://www.businessoffashion.com/", "rss": "https://www.businessoffashion.com/feed"},
{"name": "Fashionista", "website": "https://fashionista.com/", "rss": "https://fashionista.com/rss.xml"},
{"name": "The Zoe Report", "website": "https://www.thezoereport.com/", "rss": "https://www.thezoereport.com/rss"},
],
"e-commerce": [
{"name": "Retail Dive", "website": "https://retaildive.com/", "rss": "https://www.retaildive.com/feeds/news/"},
{"name": "Modern Retail", "website": "https://modernretail.co/", "rss": "https://www.modernretail.co/feed/"},
{"name": "Glossy", "website": "https://glossy.co/", "rss": "https://glossy.co/feed/"},
],
"social-media-marketing": [
{"name": "Social Media Examiner", "website": "https://socialmediaexaminer.com/", "rss": "https://socialmediaexaminer.com/feed/"},
{"name": "Later Blog", "website": "https://later.com/blog/", "rss": "https://later.com/blog/feed/"},
{"name": "Sprout Social Insights", "website": "https://sproutsocial.com/insights/", "rss": "https://sproutsocial.com/insights/feed/"},
{"name": "Creator Science", "website": "https://creatorscience.com/", "rss": "https://creatorscience.com/rss/"},
],
}
FEED_CATEGORIES = [
{"slug": "thoi-trang-viet-nam", "label": "Thời trang Việt Nam", "icon": "flag"},
{"slug": "fashion-global", "label": "Fashion Global", "icon": "globe"},
{"slug": "e-commerce", "label": "E-commerce", "icon": "shopping-cart"},
{"slug": "social-media-marketing", "label": "Social Marketing", "icon": "trending-up"},
]
def get_templates(category: str = "all", limit: int = 50) -> list[dict]:
templates = TEMPLATES
if category and category != "all":
templates = [t for t in templates if t.get("category") == category]
featured = [t for t in templates if t["id"] in FEATURED_IDS]
rest = [t for t in templates if t["id"] not in FEATURED_IDS]
return (featured + rest)[:limit]
def get_template(template_id: int) -> dict | None:
return next((t for t in TEMPLATES if t["id"] == template_id), None)
def get_categories() -> list[dict]:
return CATEGORIES
def search_templates(query: str) -> list[dict]:
q = query.lower()
return [
t for t in TEMPLATES
if q in t.get("title", "").lower()
or q in t.get("description", "").lower()
or any(q in tag.lower() for tag in t.get("tags", []))
]
def get_feeds(category: str) -> list[dict]:
feeds = FASHION_RSS_FEEDS.get(category, [])
from urllib.parse import urlsplit
result = []
for f in feeds:
url = f.get("website", "")
parsed = urlsplit(url)
favicon = f"{parsed.scheme}://{parsed.netloc}/favicon.ico" if parsed.netloc else ""
result.append({**f, "favicon": favicon})
return result
def get_feed_categories() -> list[dict]:
return FEED_CATEGORIES
"""Media package."""
from .image_processor import (
generate_image_thumbnail, resize_for_platform, get_image_dimensions,
generate_video_thumbnail, get_video_metadata,
save_uploaded_asset, list_assets, get_asset, delete_asset,
PLATFORM_SIZES,
)
__all__ = [
"generate_image_thumbnail", "resize_for_platform", "get_image_dimensions",
"generate_video_thumbnail", "get_video_metadata",
"save_uploaded_asset", "list_assets", "get_asset", "delete_asset",
"PLATFORM_SIZES",
]
"""
Image & Video Media Processor
Adapted từ BrightBean Studio apps/media_library/services.py
Xử lý ảnh sản phẩm Canifa cho social media: resize/crop/thumbnail
"""
import io
import json
import logging
import os
import subprocess
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
MEDIA_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..", "data", "media"
)
MEDIA_INDEX_FILE = os.path.join(MEDIA_DIR, "index.json")
# Platform-specific output sizes (px)
PLATFORM_SIZES = {
"instagram_square": (1080, 1080), # Instagram 1:1
"instagram_portrait": (1080, 1350), # Instagram 4:5
"instagram_story": (1080, 1920), # Instagram 9:16 / Story
"tiktok": (1080, 1920), # TikTok 9:16
"facebook": (1200, 630), # Facebook link preview
"facebook_square": (1080, 1080), # Facebook post
"thumbnail": (400, 400), # Dashboard thumbnail
}
def _ensure_dir():
os.makedirs(MEDIA_DIR, exist_ok=True)
thumbs_dir = os.path.join(MEDIA_DIR, "thumbs")
os.makedirs(thumbs_dir, exist_ok=True)
def _load_index() -> list[dict]:
_ensure_dir()
if not os.path.exists(MEDIA_INDEX_FILE):
return []
try:
with open(MEDIA_INDEX_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return []
def _save_index(data: list[dict]):
with open(MEDIA_INDEX_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2, default=str)
def _append_asset(asset: dict):
index = _load_index()
index.insert(0, asset)
_save_index(index[:1000])
# ─── Image Processing (requires Pillow) ──────────────────────────────────────
def generate_image_thumbnail(
image_bytes: bytes,
size: tuple[int, int] = (400, 400),
output_format: str = "JPEG",
) -> bytes:
"""
Generate thumbnail từ image bytes.
Từ BrightBean: LANCZOS resize, RGBA→RGB convert để đảm bảo JPEG compat.
"""
try:
from PIL import Image
except ImportError:
logger.warning("Pillow không có — skip thumbnail generation")
return image_bytes
img = Image.open(io.BytesIO(image_bytes))
# Convert RGBA, P modes sang RGB (JPEG không hỗ trợ)
if img.mode in ("RGBA", "P", "LA"):
background = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "RGBA":
background.paste(img, mask=img.split()[3])
else:
background.paste(img)
img = background
elif img.mode != "RGB":
img = img.convert("RGB")
# LANCZOS (Antialiased resize — chất lượng tốt nhất)
img.thumbnail(size, Image.LANCZOS)
out = io.BytesIO()
img.save(out, format=output_format, quality=85, optimize=True)
return out.getvalue()
def resize_for_platform(
image_bytes: bytes,
platform: str,
) -> bytes:
"""
Resize và crop ảnh theo đúng kích thước platform.
Canifa use case: ảnh sản phẩm → fit cho từng social platform.
"""
try:
from PIL import Image, ImageOps
except ImportError:
return image_bytes
size = PLATFORM_SIZES.get(platform)
if not size:
return image_bytes
img = Image.open(io.BytesIO(image_bytes))
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
# ImageOps.fit: crop center + resize giữ nguyên tỷ lệ
img = ImageOps.fit(img, size, method=Image.LANCZOS, centering=(0.5, 0.5))
out = io.BytesIO()
img.save(out, format="JPEG", quality=90, optimize=True)
return out.getvalue()
def get_image_dimensions(image_bytes: bytes) -> dict:
"""Extract width/height từ image bytes."""
try:
from PIL import Image
img = Image.open(io.BytesIO(image_bytes))
return {"width": img.width, "height": img.height, "mode": img.mode}
except Exception:
return {"width": 0, "height": 0, "mode": "unknown"}
# ─── Video Processing (requires ffmpeg) ──────────────────────────────────────
def generate_video_thumbnail(video_path: str, timestamp_seconds: float = 1.0) -> bytes | None:
"""
Extract frame từ video dùng ffmpeg.
Từ BrightBean: dùng ffmpeg -ss {ts} -vframes 1 -f image2pipe.
"""
cmd = [
"ffmpeg",
"-ss", str(timestamp_seconds),
"-i", video_path,
"-vframes", "1",
"-f", "image2",
"-vf", "scale=400:400:force_original_aspect_ratio=decrease",
"pipe:1",
"-loglevel", "quiet",
]
try:
result = subprocess.run(cmd, capture_output=True, timeout=30)
if result.returncode == 0 and result.stdout:
return result.stdout
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
logger.warning("ffmpeg thumbnail failed: %s", e)
return None
def get_video_metadata(video_path: str) -> dict:
"""
Extract video metadata via ffprobe.
Từ BrightBean: parse JSON output từ ffprobe.
"""
cmd = [
"ffprobe",
"-v", "quiet",
"-print_format", "json",
"-show_streams",
"-show_format",
video_path,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
if result.returncode != 0:
return {}
data = json.loads(result.stdout)
video_stream = next(
(s for s in data.get("streams", []) if s.get("codec_type") == "video"),
{},
)
fmt = data.get("format", {})
return {
"duration": float(fmt.get("duration", 0)),
"width": video_stream.get("width", 0),
"height": video_stream.get("height", 0),
"codec": video_stream.get("codec_name", ""),
"size_bytes": int(fmt.get("size", 0)),
}
except Exception as e:
logger.warning("ffprobe Error: %s", e)
return {}
# ─── Asset Management ─────────────────────────────────────────────────────────
def save_uploaded_asset(
filename: str,
content_bytes: bytes,
content_type: str,
uploaded_by: str = "user",
tags: list[str] | None = None,
) -> dict:
"""
Lưu asset vào media library, generate thumbnail.
Returns asset metadata dict.
"""
_ensure_dir()
ext = Path(filename).suffix.lower()
asset_id = uuid.uuid4().hex
stored_name = f"{asset_id}{ext}"
asset_path = os.path.join(MEDIA_DIR, stored_name)
with open(asset_path, "wb") as f:
f.write(content_bytes)
is_image = content_type.startswith("image/")
is_video = content_type.startswith("video/")
# Generate thumbnail
thumb_path = None
try:
if is_image:
thumb_bytes = generate_image_thumbnail(content_bytes)
thumb_path = os.path.join(MEDIA_DIR, "thumbs", f"{asset_id}.jpg")
with open(thumb_path, "wb") as f:
f.write(thumb_bytes)
elif is_video:
thumb_bytes = generate_video_thumbnail(asset_path)
if thumb_bytes:
thumb_path = os.path.join(MEDIA_DIR, "thumbs", f"{asset_id}.jpg")
with open(thumb_path, "wb") as f:
f.write(thumb_bytes)
except Exception as e:
logger.warning("Thumbnail generation failed: %s", e)
# Metadata
if is_image:
dims = get_image_dimensions(content_bytes)
elif is_video:
dims = get_video_metadata(asset_path)
else:
dims = {}
asset = {
"id": asset_id,
"filename": filename,
"stored_name": stored_name,
"content_type": content_type,
"size_bytes": len(content_bytes),
"path": asset_path,
"thumb_path": thumb_path,
"url": f"/api/media/{asset_id}",
"thumb_url": f"/api/media/{asset_id}/thumb" if thumb_path else None,
"uploaded_by": uploaded_by,
"tags": tags or [],
"metadata": dims,
"created_at": datetime.now(timezone.utc).isoformat(),
}
_append_asset(asset)
logger.info("📁 Asset saved: %s (%d bytes)", filename, len(content_bytes))
return asset
def list_assets(
content_type: str | None = None,
tag: str | None = None,
limit: int = 50,
offset: int = 0,
) -> dict:
assets = _load_index()
if content_type:
assets = [a for a in assets if a.get("content_type", "").startswith(content_type)]
if tag:
assets = [a for a in assets if tag in a.get("tags", [])]
total = len(assets)
return {"total": total, "assets": assets[offset: offset + limit]}
def get_asset(asset_id: str) -> dict | None:
for a in _load_index():
if a.get("id") == asset_id:
return a
return None
def delete_asset(asset_id: str) -> bool:
index = _load_index()
asset = next((a for a in index if a.get("id") == asset_id), None)
if not asset:
return False
try:
if os.path.exists(asset.get("path", "")):
os.remove(asset["path"])
if asset.get("thumb_path") and os.path.exists(asset["thumb_path"]):
os.remove(asset["thumb_path"])
except OSError as e:
logger.warning("delete_asset: %s", e)
_save_index([a for a in index if a.get("id") != asset_id])
return True
"""Notification package."""
from .engine import notify, get_notifications, mark_read, mark_all_read, get_unread_count, check_negative_spike
from .events import EventType, Channel
__all__ = [
"notify", "get_notifications", "mark_read", "mark_all_read",
"get_unread_count", "check_negative_spike",
"EventType", "Channel",
]
"""
Notification Engine
Ported & adapted từ BrightBean Studio apps/notifications/engine.py
Chuyển từ Django → pure Python + JSON file storage (thay DB)
Single entry point: notify(event_type, title, body, data)
"""
import hashlib
import hmac
import json
import logging
import os
import urllib.request
from datetime import datetime, timezone
from typing import Any
from .events import Channel, DEFAULT_CHANNELS, NON_CRITICAL, EventType
logger = logging.getLogger(__name__)
# ─── Config ────────────────────────────────────────────────────────────────
NOTIFICATIONS_FILE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..", "data", "notifications.json"
)
MAX_NOTIFICATIONS = 500
WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "canifa_webhook_secret")
SMTP_HOST = os.getenv("SMTP_HOST", "")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASS = os.getenv("SMTP_PASS", "")
ALERT_EMAIL = os.getenv("ALERT_EMAIL", "") # email nhận alerts
SLACK_WEBHOOK_URL = os.getenv("SLACK_WEBHOOK_URL", "") # optional Slack hook
# Negative rate threshold để trigger NEGATIVE_SPIKE alert
NEGATIVE_SPIKE_THRESHOLD = float(os.getenv("NEGATIVE_SPIKE_THRESHOLD", "30.0"))
# ─── Storage ────────────────────────────────────────────────────────────────
def _ensure_dir():
os.makedirs(os.path.dirname(NOTIFICATIONS_FILE), exist_ok=True)
def _load_notifications() -> list[dict]:
_ensure_dir()
if not os.path.exists(NOTIFICATIONS_FILE):
return []
try:
with open(NOTIFICATIONS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return []
def _save_notifications(data: list[dict]):
_ensure_dir()
with open(NOTIFICATIONS_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2, default=str)
def _append_notification(notif: dict) -> None:
existing = _load_notifications()
existing.insert(0, notif)
_save_notifications(existing[:MAX_NOTIFICATIONS])
# ─── Core ────────────────────────────────────────────────────────────────────
def notify(
event_type: str,
title: str,
body: str = "",
data: dict | None = None,
channels_override: list[str] | None = None,
) -> dict:
"""
Tạo notification và dispatch tới các channels.
Đây là single entry point — mọi module đều gọi hàm này.
Args:
event_type: Một trong EventType.*
title: Tiêu đề ngắn gọn
body: Nội dung chi tiết
data: Metadata tuỳ ý (post_id, platform, ...)
channels_override: Ghi đè default channels nếu cần
"""
if event_type not in EventType.ALL:
logger.warning("Unknown event_type: %s", event_type)
notif = {
"id": _gen_id(),
"event_type": event_type,
"title": title,
"body": body,
"data": data or {},
"is_read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
}
# Persist in-app notification
_append_notification(notif)
# Determine channels
channels = channels_override or _resolve_channels(event_type)
# Dispatch
for channel in channels:
try:
if channel == Channel.EMAIL and SMTP_USER and ALERT_EMAIL:
_dispatch_email(notif)
elif channel == Channel.WEBHOOK:
_dispatch_webhook(notif)
except Exception as e:
logger.error("Notification dispatch failed [%s] %s: %s", channel, event_type, e)
# Slack nếu có config và là critical event
if SLACK_WEBHOOK_URL and event_type not in NON_CRITICAL:
try:
_dispatch_slack(notif)
except Exception as e:
logger.error("Slack dispatch failed: %s", e)
logger.info("✅ Notification [%s]: %s", event_type, title)
return notif
def _resolve_channels(event_type: str) -> list[str]:
defaults = DEFAULT_CHANNELS.get(event_type, {})
return [ch for ch, enabled in defaults.items() if enabled]
# ─── Dispatchers ─────────────────────────────────────────────────────────────
def _dispatch_email(notif: dict) -> None:
"""Gửi email alert qua SMTP."""
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
msg = MIMEMultipart("alternative")
msg["Subject"] = f"[Canifa AI] {notif['title']}"
msg["From"] = SMTP_USER
msg["To"] = ALERT_EMAIL
text_part = MIMEText(
f"{notif['title']}\n\n{notif['body']}\n\nEvent: {notif['event_type']}\nTime: {notif['created_at']}",
"plain", "utf-8"
)
html_part = MIMEText(f"""
<div style="font-family:Inter,sans-serif;max-width:600px;margin:0 auto;padding:24px">
<div style="background:#4f46e5;color:white;padding:16px 20px;border-radius:8px 8px 0 0">
<strong>Canifa AI Notification</strong>
</div>
<div style="border:1px solid #e5e7eb;border-top:none;padding:20px;border-radius:0 0 8px 8px">
<h2 style="color:#1a1a2e;margin:0 0 8px">{notif['title']}</h2>
<p style="color:#555;margin:0 0 16px">{notif.get('body','')}</p>
<div style="background:#f9fafb;padding:12px;border-radius:6px;font-size:13px;color:#888">
Event: <code>{notif['event_type']}</code><br>
Time: {notif['created_at']}
</div>
</div>
</div>
""", "html", "utf-8")
msg.attach(text_part)
msg.attach(html_part)
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
server.starttls()
server.login(SMTP_USER, SMTP_PASS)
server.sendmail(SMTP_USER, ALERT_EMAIL, msg.as_string())
logger.info("📧 Email sent to %s: %s", ALERT_EMAIL, notif["title"])
def _dispatch_webhook(notif: dict) -> None:
"""Gửi notification qua HMAC-signed HTTP POST webhook."""
webhook_url = notif.get("data", {}).get("webhook_url") or os.getenv("NOTIFICATION_WEBHOOK_URL", "")
if not webhook_url:
return
payload = json.dumps({
"event_type": notif["event_type"],
"title": notif["title"],
"body": notif["body"],
"data": notif["data"],
"created_at": notif["created_at"],
}, default=str).encode("utf-8")
signature = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode(), payload, hashlib.sha256
).hexdigest()
req = urllib.request.Request(
webhook_url,
data=payload,
headers={
"Content-Type": "application/json",
"X-Canifa-Signature": signature,
"X-Event-Type": notif["event_type"],
},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status >= 400:
raise RuntimeError(f"Webhook returned HTTP {resp.status}")
logger.info("🔗 Webhook dispatched: %s", notif["event_type"])
def _dispatch_slack(notif: dict) -> None:
"""Gửi notification tới Slack channel qua Incoming Webhook."""
emoji = {
"negative_spike": "🚨",
"content_failed": "❌",
"content_published": "✅",
"api_token_expired": "⚠️",
"content_submitted": "📋",
"ai_rules_updated": "🧠",
}.get(notif["event_type"], "📢")
payload = json.dumps({
"text": f"{emoji} *{notif['title']}*\n{notif.get('body', '')}",
"username": "Canifa AI",
"icon_emoji": ":robot_face:",
}).encode("utf-8")
req = urllib.request.Request(
SLACK_WEBHOOK_URL,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5):
pass
logger.info("💬 Slack notified: %s", notif["event_type"])
# ─── Helper ──────────────────────────────────────────────────────────────────
def _gen_id() -> str:
import secrets
return secrets.token_hex(8)
# ─── API helpers (dùng trong route) ──────────────────────────────────────────
def get_notifications(limit: int = 50, unread_only: bool = False) -> list[dict]:
notifications = _load_notifications()
if unread_only:
notifications = [n for n in notifications if not n.get("is_read")]
return notifications[:limit]
def mark_read(notification_id: str) -> bool:
notifications = _load_notifications()
for n in notifications:
if n.get("id") == notification_id:
n["is_read"] = True
_save_notifications(notifications)
return True
return False
def mark_all_read() -> int:
notifications = _load_notifications()
count = sum(1 for n in notifications if not n.get("is_read"))
for n in notifications:
n["is_read"] = True
_save_notifications(notifications)
return count
def get_unread_count() -> int:
return sum(1 for n in _load_notifications() if not n.get("is_read"))
# ─── Auto-check negative spike ───────────────────────────────────────────────
def check_negative_spike() -> bool:
"""
Kiểm tra nếu tỷ lệ negative messages vượt threshold → gửi alert.
Gọi từ social_inbox_route hoặc background task.
Returns True nếu có spike.
"""
from common.social.inbox_webhook import _load_messages # type: ignore
try:
from api.social_inbox_route import _load_messages as load_msgs
messages = load_msgs()
except Exception:
return False
if len(messages) < 10: # Không đủ data để đánh giá
return False
recent = messages[:50]
neg = sum(1 for m in recent if m.get("sentiment") == "negative")
rate = neg / len(recent) * 100
if rate >= NEGATIVE_SPIKE_THRESHOLD:
notify(
event_type=EventType.NEGATIVE_SPIKE,
title=f"🚨 Negative spike: {rate:.1f}% ({neg}/{len(recent)} messages)",
body=f"Tỷ lệ negative comments trên social media đang ở mức {rate:.1f}% "
f"(ngưỡng cảnh báo: {NEGATIVE_SPIKE_THRESHOLD}%). "
"AI learning loop đã được kích hoạt.",
data={"negative_rate": rate, "sample_size": len(recent)},
)
return True
return False
"""
Notification Events — Constants
Ported & adapted từ BrightBean Studio apps/notifications/models.py
"""
class EventType:
# Social feedback events
NEW_SOCIAL_MESSAGE = "new_social_message"
NEGATIVE_SPIKE = "negative_spike" # Negative rate vượt ngưỡng
SOCIAL_SENTIMENT_REPORT= "social_sentiment_report" # Báo cáo hàng tuần
# AI Content pipeline
CONTENT_SUBMITTED = "content_submitted" # AI tạo content chờ review
CONTENT_APPROVED = "content_approved" # Marketing đã approve
CONTENT_REJECTED = "content_rejected" # Marketing reject
CONTENT_CHANGES = "content_changes_requested"
CONTENT_PUBLISHED = "content_published" # Đã publish lên social
CONTENT_FAILED = "content_failed" # Publish thất bại
# Learning loop events
AI_RULES_UPDATED = "ai_rules_updated" # Feedback loop cập nhật rules
FEEDBACK_MILESTONE = "feedback_milestone" # Đạt N feedbacks
# System
PUBLISH_RETRY = "publish_retry" # Đang retry publish
API_TOKEN_EXPIRED = "api_token_expired" # Token FB/IG hết hạn
ALL = [
NEW_SOCIAL_MESSAGE, NEGATIVE_SPIKE, SOCIAL_SENTIMENT_REPORT,
CONTENT_SUBMITTED, CONTENT_APPROVED, CONTENT_REJECTED,
CONTENT_CHANGES, CONTENT_PUBLISHED, CONTENT_FAILED,
AI_RULES_UPDATED, FEEDBACK_MILESTONE,
PUBLISH_RETRY, API_TOKEN_EXPIRED,
]
class Channel:
IN_APP = "in_app"
EMAIL = "email"
WEBHOOK = "webhook"
# Default channels per event type
DEFAULT_CHANNELS: dict[str, dict[str, bool]] = {
EventType.NEW_SOCIAL_MESSAGE: {Channel.IN_APP: True, Channel.EMAIL: False, Channel.WEBHOOK: False},
EventType.NEGATIVE_SPIKE: {Channel.IN_APP: True, Channel.EMAIL: True, Channel.WEBHOOK: True},
EventType.SOCIAL_SENTIMENT_REPORT: {Channel.IN_APP: True, Channel.EMAIL: True, Channel.WEBHOOK: False},
EventType.CONTENT_SUBMITTED: {Channel.IN_APP: True, Channel.EMAIL: True, Channel.WEBHOOK: False},
EventType.CONTENT_APPROVED: {Channel.IN_APP: True, Channel.EMAIL: False, Channel.WEBHOOK: False},
EventType.CONTENT_REJECTED: {Channel.IN_APP: True, Channel.EMAIL: True, Channel.WEBHOOK: False},
EventType.CONTENT_CHANGES: {Channel.IN_APP: True, Channel.EMAIL: True, Channel.WEBHOOK: False},
EventType.CONTENT_PUBLISHED: {Channel.IN_APP: True, Channel.EMAIL: False, Channel.WEBHOOK: True},
EventType.CONTENT_FAILED: {Channel.IN_APP: True, Channel.EMAIL: True, Channel.WEBHOOK: True},
EventType.AI_RULES_UPDATED: {Channel.IN_APP: True, Channel.EMAIL: False, Channel.WEBHOOK: False},
EventType.FEEDBACK_MILESTONE: {Channel.IN_APP: True, Channel.EMAIL: True, Channel.WEBHOOK: False},
EventType.PUBLISH_RETRY: {Channel.IN_APP: True, Channel.EMAIL: False, Channel.WEBHOOK: False},
EventType.API_TOKEN_EXPIRED: {Channel.IN_APP: True, Channel.EMAIL: True, Channel.WEBHOOK: False},
}
# Non-critical: suppress khi quiet hours
NON_CRITICAL = {
EventType.NEW_SOCIAL_MESSAGE,
EventType.AI_RULES_UPDATED,
EventType.CONTENT_PUBLISHED,
EventType.SOCIAL_SENTIMENT_REPORT,
}
"""
Social Media Integration Layer
Ported & adapted từ BrightBean Studio (open-source)
Tích hợp: Nhận feedback từ Facebook/Instagram/TikTok comment → Canifa AI Learning Loop
"""
from .sentiment_vi import analyze_sentiment
from .inbox_webhook import parse_facebook_webhook, parse_tiktok_webhook
__all__ = ["analyze_sentiment", "parse_facebook_webhook", "parse_tiktok_webhook"]
"""
Content Approval Gate
Ported & adapted từ BrightBean Studio apps/approvals/services.py
State machine: draft → pending_review → approved → scheduled → published
→ rejected / changes_requested
Canifa use case: AI generate outfit suggestion content
→ marketing review → approve → auto-schedule publish lên social media
"""
import json
import logging
import os
from datetime import datetime, timezone
from typing import Optional
from common.notification.engine import notify
from common.notification.events import EventType
logger = logging.getLogger(__name__)
CONTENT_FILE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..", "data", "social_content.json"
)
# ─── Valid state transitions ─────────────────────────────────────────────────
TRANSITIONS = {
"draft": ["pending_review"],
"pending_review": ["approved", "rejected", "changes_requested"],
"changes_requested": ["pending_review", "draft"],
"rejected": ["draft"],
"approved": ["scheduled", "draft"],
"scheduled": ["published", "cancelled", "failed"],
"published": [],
"cancelled": [],
"failed": ["scheduled"], # retry
}
STATUS_LABELS = {
"draft": "📝 Nháp",
"pending_review": "🔍 Chờ duyệt",
"changes_requested": "✏️ Cần chỉnh sửa",
"rejected": "❌ Từ chối",
"approved": "✅ Đã duyệt",
"scheduled": "⏰ Đã lên lịch",
"published": "🚀 Đã đăng",
"cancelled": "🚫 Đã huỷ",
"failed": "💥 Thất bại",
}
# ─── Storage helpers ─────────────────────────────────────────────────────────
def _ensure_dir():
os.makedirs(os.path.dirname(CONTENT_FILE), exist_ok=True)
def _load_contents() -> list[dict]:
_ensure_dir()
if not os.path.exists(CONTENT_FILE):
return []
try:
with open(CONTENT_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return []
def _save_contents(data: list[dict]):
_ensure_dir()
with open(CONTENT_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2, default=str)
def _find_content(content_id: str) -> tuple[dict | None, int]:
contents = _load_contents()
for i, c in enumerate(contents):
if c.get("id") == content_id:
return c, i
return None, -1
def _gen_id() -> str:
import secrets
return "cnt_" + secrets.token_hex(6)
# ─── Public API ──────────────────────────────────────────────────────────────
def create_content(
title: str,
caption: str,
platforms: list[str],
image_urls: list[str] | None = None,
created_by: str = "ai_agent",
tags: list[str] | None = None,
metadata: dict | None = None,
) -> dict:
"""
Tạo content mới (bắt đầu từ draft).
Thường được gọi bởi AI stylist engine khi generate outfit suggestion.
"""
content = {
"id": _gen_id(),
"title": title,
"caption": caption,
"platforms": platforms, # ["facebook", "instagram", "tiktok"]
"image_urls": image_urls or [],
"tags": tags or [],
"status": "draft",
"created_by": created_by,
"metadata": metadata or {},
"review_comment": "",
"history": [],
"created_at": datetime.now(timezone.utc).isoformat(),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
contents = _load_contents()
contents.insert(0, content)
_save_contents(contents)
logger.info("📝 Content created: %s", content["id"])
return content
def _transition(content_id: str, new_status: str, comment: str = "", actor: str = "system") -> dict:
"""Internal: thực hiện state transition."""
contents = _load_contents()
content = None
idx = -1
for i, c in enumerate(contents):
if c.get("id") == content_id:
content = c
idx = i
break
if content is None:
raise ValueError(f"Content {content_id} not found")
current = content["status"]
allowed = TRANSITIONS.get(current, [])
if new_status not in allowed:
raise ValueError(f"Không thể chuyển từ '{current}' → '{new_status}'. Allowed: {allowed}")
# Ghi lịch sử transition
content.setdefault("history", []).append({
"from": current,
"to": new_status,
"comment": comment,
"actor": actor,
"at": datetime.now(timezone.utc).isoformat(),
})
content["status"] = new_status
content["review_comment"] = comment
content["updated_at"] = datetime.now(timezone.utc).isoformat()
contents[idx] = content
_save_contents(contents)
return content
def submit_for_review(content_id: str, submitted_by: str = "ai_agent") -> dict:
"""Submit content cho marketing review."""
content = _transition(content_id, "pending_review", actor=submitted_by)
notify(
event_type=EventType.CONTENT_SUBMITTED,
title=f"Content mới chờ duyệt: {content['title'][:50]}",
body=f"AI đã tạo content và submit để review.\nPlatforms: {', '.join(content.get('platforms', []))}",
data={"content_id": content_id, "platforms": content.get("platforms")},
)
return content
def approve_content(content_id: str, approved_by: str, comment: str = "") -> dict:
"""Marketing approve content — tiếp theo sẽ move vào queue scheduling."""
content = _transition(content_id, "approved", comment=comment, actor=approved_by)
notify(
event_type=EventType.CONTENT_APPROVED,
title=f"✅ Content đã được duyệt: {content['title'][:50]}",
body=f"Duyệt bởi {approved_by}. {comment}",
data={"content_id": content_id},
)
return content
def reject_content(content_id: str, rejected_by: str, comment: str) -> dict:
"""Marketing reject content — AI cần học từ lý do này."""
if not comment.strip():
raise ValueError("Phải có lý do khi từ chối content.")
content = _transition(content_id, "rejected", comment=comment, actor=rejected_by)
notify(
event_type=EventType.CONTENT_REJECTED,
title=f"❌ Content bị từ chối: {content['title'][:50]}",
body=f"Từ chối bởi {rejected_by}.\nLý do: {comment}",
data={"content_id": content_id, "reject_reason": comment},
)
# Feed rejection reason vào learning loop
_feed_rejection_to_learning_loop(content, comment)
return content
def request_changes(content_id: str, requested_by: str, comment: str) -> dict:
"""Marketing yêu cầu chỉnh sửa — không reject hoàn toàn."""
if not comment.strip():
raise ValueError("Phải mô tả cần chỉnh sửa gì.")
content = _transition(content_id, "changes_requested", comment=comment, actor=requested_by)
notify(
event_type=EventType.CONTENT_CHANGES,
title=f"✏️ Cần chỉnh sửa: {content['title'][:50]}",
body=f"Yêu cầu bởi {requested_by}.\nCần sửa: {comment}",
data={"content_id": content_id, "change_request": comment},
)
return content
def schedule_content(content_id: str, scheduled_at: str, actor: str = "system") -> dict:
"""Move approved content vào scheduled (đã có thời gian publish)."""
content = _transition(content_id, "scheduled", actor=actor)
contents = _load_contents()
for c in contents:
if c["id"] == content_id:
c["scheduled_at"] = scheduled_at
c["updated_at"] = datetime.now(timezone.utc).isoformat()
_save_contents(contents)
return content
def mark_published(content_id: str, platform_results: dict | None = None) -> dict:
"""Mark content là đã publish thành công."""
content = _transition(content_id, "published", actor="publisher")
contents = _load_contents()
for c in contents:
if c["id"] == content_id:
c["published_at"] = datetime.now(timezone.utc).isoformat()
c["platform_results"] = platform_results or {}
_save_contents(contents)
notify(
event_type=EventType.CONTENT_PUBLISHED,
title=f"🚀 Đã đăng: {content['title'][:50]}",
body=f"Content đã được publish lên {', '.join(content.get('platforms', []))}",
data={"content_id": content_id},
)
return content
def mark_failed(content_id: str, error: str) -> dict:
"""Mark content là publish thất bại."""
content = _transition(content_id, "failed", comment=error, actor="publisher")
notify(
event_type=EventType.CONTENT_FAILED,
title=f"💥 Publish thất bại: {content['title'][:50]}",
body=f"Lỗi: {error}",
data={"content_id": content_id, "error": error},
)
return content
# ─── Queries ─────────────────────────────────────────────────────────────────
def list_contents(
status: str | None = None,
platform: str | None = None,
limit: int = 50,
offset: int = 0,
) -> dict:
contents = _load_contents()
if status:
contents = [c for c in contents if c.get("status") == status]
if platform:
contents = [c for c in contents if platform in c.get("platforms", [])]
total = len(contents)
return {"total": total, "contents": contents[offset: offset + limit]}
def get_content(content_id: str) -> dict | None:
content, _ = _find_content(content_id)
return content
def get_approval_stats() -> dict:
contents = _load_contents()
stats: dict = {"total": len(contents), "by_status": {}}
for c in contents:
s = c.get("status", "unknown")
stats["by_status"][s] = stats["by_status"].get(s, 0) + 1
return stats
# ─── Learning loop integration ───────────────────────────────────────────────
def _feed_rejection_to_learning_loop(content: dict, reason: str):
"""Feed rejected content + reason vào Canifa feedback tracker → AI học."""
try:
from common.feedback_tracker import save_feedback
save_feedback(
trace_id=f"approval_reject_{content['id']}",
rating=0,
comment=f"[CONTENT REJECTED] {reason}",
user_category="content_approval",
ai_category="rejected_content",
ai_severity="medium",
ai_summary=(
f"AI-generated content bị marketing reject.\n"
f"Title: {content.get('title', '')}\n"
f"Caption: {content.get('caption', '')[:200]}\n"
f"Lý do reject: {reason}"
),
)
logger.info("🧠 Rejection fed to learning loop: %s", content["id"])
except Exception as e:
logger.error("Failed to feed rejection to learning loop: %s", e)
"""
Social Inbox Webhook Parser
Ported & adapted từ BrightBean Studio apps/inbox/webhooks.py
Chuyển từ Django → FastAPI/asyncio, bỏ ORM dependency, thuần Python
Hỗ trợ: Facebook/Instagram webhook, TikTok comment webhook
Output: Chuẩn hóa thành SocialMessage dict → feed vào Canifa feedback_tracker
"""
import hashlib
import hmac
import json
import logging
import os
from datetime import datetime, timezone
from typing import Any
from .sentiment_vi import analyze_sentiment
logger = logging.getLogger(__name__)
# Lấy từ env (set trong .env của Canifa backend)
FACEBOOK_APP_SECRET = os.getenv("FACEBOOK_APP_SECRET", "")
FACEBOOK_WEBHOOK_VERIFY_TOKEN = os.getenv("FACEBOOK_WEBHOOK_VERIFY_TOKEN", "canifa_verify_token")
# ─────────────────────────────────────────────
# Data Model (simple dict — không cần ORM)
# ─────────────────────────────────────────────
def _build_social_message(
platform: str, # "facebook" | "instagram" | "tiktok"
message_type: str, # "comment" | "dm" | "mention"
sender_name: str,
sender_id: str,
body: str,
post_id: str = "",
raw: dict | None = None,
) -> dict:
"""Tạo chuẩn hóa SocialMessage dict để feed vào feedback_tracker."""
sentiment = analyze_sentiment(body)
return {
"source": "social",
"platform": platform,
"message_type": message_type,
"sender_name": sender_name,
"sender_id": sender_id,
"body": body,
"post_id": post_id,
"sentiment": sentiment, # "positive" | "negative" | "neutral"
"received_at": datetime.now(timezone.utc).isoformat(),
"raw": raw or {},
}
# ─────────────────────────────────────────────
# Facebook / Instagram Webhook Handler
# (Adapted từ BrightBean webhooks.py)
# ─────────────────────────────────────────────
def verify_facebook_signature(body: bytes, signature_header: str) -> bool:
"""Verify HMAC-SHA256 signature từ Facebook. Bảo vệ khỏi webhook giả."""
if not FACEBOOK_APP_SECRET:
logger.warning("⚠️ FACEBOOK_APP_SECRET chưa set — bỏ qua verify (dev mode)")
return True # Dev mode: không verify
expected = "sha256=" + hmac.new(
FACEBOOK_APP_SECRET.encode(),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature_header)
def parse_facebook_webhook(payload: dict) -> list[dict]:
"""
Parse Facebook/Instagram webhook payload → list[SocialMessage].
Hỗ trợ: comments, mentions, DM (messaging events).
"""
messages = []
for entry in payload.get("entry", []):
# Facebook Page comments & mentions
for change in entry.get("changes", []):
field = change.get("field", "")
value = change.get("value", {})
if field == "feed":
msg = _parse_fb_feed_change(value)
if msg:
messages.append(msg)
elif field == "mention":
msg = _parse_fb_mention(value)
if msg:
messages.append(msg)
# Instagram / Facebook DM (messaging events)
for messaging in entry.get("messaging", []):
msg = _parse_fb_messaging(messaging)
if msg:
messages.append(msg)
return messages
def _parse_fb_feed_change(value: dict) -> dict | None:
"""Parse một Facebook comment từ feed change event."""
comment_id = value.get("comment_id") or value.get("id")
if not comment_id:
return None
from_data = value.get("from", {})
body = value.get("message", "").strip()
if not body:
return None
post_id = value.get("post_id", "")
platform = "instagram" if value.get("item") == "comment" and "instagram" in str(value).lower() else "facebook"
return _build_social_message(
platform=platform,
message_type="comment",
sender_name=from_data.get("name", "Unknown"),
sender_id=from_data.get("id", ""),
body=body,
post_id=post_id,
raw=value,
)
def _parse_fb_mention(value: dict) -> dict | None:
"""Parse Facebook mention event."""
mention_id = value.get("post_id") or value.get("id")
if not mention_id:
return None
from_data = value.get("from", {})
body = value.get("message", "").strip()
if not body:
return None
return _build_social_message(
platform="facebook",
message_type="mention",
sender_name=from_data.get("name", "Unknown"),
sender_id=from_data.get("id", ""),
body=body,
post_id=mention_id,
raw=value,
)
def _parse_fb_messaging(messaging: dict) -> dict | None:
"""Parse Facebook/Instagram DM message."""
message_data = messaging.get("message", {})
mid = message_data.get("mid")
if not mid:
return None
sender = messaging.get("sender", {})
body = message_data.get("text", "").strip()
if not body:
return None # Bỏ qua message ảnh/sticker không có text
return _build_social_message(
platform="instagram",
message_type="dm",
sender_name=sender.get("name", sender.get("id", "Unknown")),
sender_id=sender.get("id", ""),
body=body,
post_id="",
raw=messaging,
)
# ─────────────────────────────────────────────
# TikTok Webhook Handler
# TikTok gửi comment events qua webhook
# ─────────────────────────────────────────────
def parse_tiktok_webhook(payload: dict) -> list[dict]:
"""
Parse TikTok comment webhook payload → list[SocialMessage].
TikTok webhook format khác Facebook.
"""
messages = []
# TikTok comment notification
comments = payload.get("data", {}).get("comments", [])
for comment in comments:
body = comment.get("text", "").strip()
if not body:
continue
user = comment.get("user", {})
messages.append(_build_social_message(
platform="tiktok",
message_type="comment",
sender_name=user.get("display_name", user.get("unique_id", "Unknown")),
sender_id=user.get("open_id", ""),
body=body,
post_id=comment.get("video_id", ""),
raw=comment,
))
return messages
# ─────────────────────────────────────────────
# Facebook Webhook Verification (for GET requests)
# ─────────────────────────────────────────────
def handle_facebook_verify(mode: str, token: str, challenge: str) -> str | None:
"""
Xử lý Facebook webhook verification handshake (GET request).
Returns challenge string nếu valid, None nếu invalid.
"""
if mode == "subscribe" and token == FACEBOOK_WEBHOOK_VERIFY_TOKEN:
logger.info("✅ Facebook webhook verified!")
return challenge
logger.warning("❌ Facebook webhook verification failed. Token mismatch.")
return None
"""
Content Post Queue + Calendar Scheduler
Ported & adapted từ BrightBean Studio apps/calendar/services.py
Logic: define PostingSlots theo weekday → auto-assign datetime cho queued content
"""
import json
import logging
import os
from datetime import datetime, time, timedelta, timezone
from typing import Optional
logger = logging.getLogger(__name__)
QUEUE_FILE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..", "data", "post_queue.json"
)
SLOTS_FILE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..", "data", "posting_slots.json"
)
# ─── Default slots (từ BrightBean — non-round times để tránh shadow ban) ────
# Canifa: Đăng T2/T3/T4/T5 vào giờ "vàng" sáng
DEFAULT_SLOTS = [
{"day": 0, "hour": 9, "minute": 24, "label": "Thứ 2 sáng"}, # Monday
{"day": 0, "hour": 11, "minute": 10, "label": "Thứ 2 trưa"},
{"day": 1, "hour": 9, "minute": 55, "label": "Thứ 3 sáng"}, # Tuesday
{"day": 2, "hour": 9, "minute": 30, "label": "Thứ 4 sáng"}, # Wednesday
{"day": 2, "hour": 11, "minute": 32, "label": "Thứ 4 trưa"},
{"day": 3, "hour": 9, "minute": 38, "label": "Thứ 5 sáng"}, # Thursday
{"day": 4, "hour": 10, "minute": 17, "label": "Thứ 6 sáng"}, # Friday
{"day": 4, "hour": 14, "minute": 0, "label": "Thứ 6 chiều"},
{"day": 5, "hour": 10, "minute": 0, "label": "Thứ 7 sáng"}, # Saturday
]
# ─── Storage ─────────────────────────────────────────────────────────────────
def _ensure_dir():
for f in [QUEUE_FILE, SLOTS_FILE]:
os.makedirs(os.path.dirname(f), exist_ok=True)
def _load_queue() -> list[dict]:
_ensure_dir()
if not os.path.exists(QUEUE_FILE):
return []
try:
with open(QUEUE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return []
def _save_queue(data: list[dict]):
_ensure_dir()
with open(QUEUE_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2, default=str)
def _load_slots() -> list[dict]:
_ensure_dir()
if not os.path.exists(SLOTS_FILE):
_save_slots(DEFAULT_SLOTS)
return DEFAULT_SLOTS
try:
with open(SLOTS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return DEFAULT_SLOTS
def _save_slots(data: list[dict]):
_ensure_dir()
with open(SLOTS_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2, default=str)
# ─── Core slot logic (from BrightBean calendar/services.py) ─────────────────
def _next_slot_datetimes(after_dt: datetime, count: int = 20) -> list[datetime]:
"""
Tính toán N thời điểm publish tiếp theo dựa trên PostingSlots.
Walk 60 ngày tới để tìm đủ slots.
"""
slots = _load_slots()
if not slots:
return []
results = []
current_date = after_dt.date()
for day_offset in range(60):
check_date = current_date + timedelta(days=day_offset)
weekday = check_date.weekday() # 0=Monday
for slot in slots:
if slot.get("day") != weekday:
continue
slot_dt = datetime.combine(
check_date,
time(slot.get("hour", 9), slot.get("minute", 0)),
tzinfo=timezone.utc,
)
if slot_dt <= after_dt:
continue
results.append(slot_dt)
if len(results) >= count:
return results
return results
def assign_queue_slots() -> list[dict]:
"""
Recalculate scheduled_at cho tất cả queued content.
Gọi sau mỗi khi add/remove/reorder queue.
"""
queue = _load_queue()
if not queue:
return []
now = datetime.now(timezone.utc)
slot_times = _next_slot_datetimes(now, count=len(queue) + 5)
for idx, entry in enumerate(queue):
if idx < len(slot_times):
entry["scheduled_at"] = slot_times[idx].isoformat()
else:
entry["scheduled_at"] = None
_save_queue(queue)
return queue
def add_to_queue(content_id: str, priority: bool = False) -> list[dict]:
"""
Thêm content vào queue.
priority=True → đẩy lên đầu (cho campaign quan trọng).
"""
queue = _load_queue()
# Remove nếu đã có
queue = [e for e in queue if e.get("content_id") != content_id]
entry = {
"content_id": content_id,
"position": 0,
"added_at": datetime.now(timezone.utc).isoformat(),
"scheduled_at": None,
}
if priority:
queue.insert(0, entry)
else:
queue.append(entry)
# Update positions
for i, e in enumerate(queue):
e["position"] = i
_save_queue(queue)
return assign_queue_slots()
def remove_from_queue(content_id: str) -> list[dict]:
"""Xoá content khỏi queue."""
queue = _load_queue()
queue = [e for e in queue if e.get("content_id") != content_id]
for i, e in enumerate(queue):
e["position"] = i
_save_queue(queue)
return assign_queue_slots()
def reorder_queue(ordered_content_ids: list[str]) -> list[dict]:
"""Reorder queue theo danh sách content_id (drag-drop)."""
queue = _load_queue()
queue_map = {e["content_id"]: e for e in queue}
new_queue = []
for idx, cid in enumerate(ordered_content_ids):
if cid in queue_map:
entry = queue_map[cid]
entry["position"] = idx
new_queue.append(entry)
# Append entries không có trong ordered list
existing_ids = set(ordered_content_ids)
for e in queue:
if e["content_id"] not in existing_ids:
new_queue.append(e)
_save_queue(new_queue)
return assign_queue_slots()
def get_queue() -> list[dict]:
"""Lấy queue hiện tại với thông tin content đính kèm."""
queue = _load_queue()
# Enrich với content data
try:
from common.social.approval_gate import get_content
enriched = []
for entry in queue:
content = get_content(entry["content_id"])
enriched.append({**entry, "content": content})
return enriched
except Exception:
return queue
def get_due_for_publish(now: datetime | None = None) -> list[dict]:
"""
Lấy danh sách queue entries đã đến giờ publish.
Dùng bởi PublishEngine để biết publish cái gì.
"""
if now is None:
now = datetime.now(timezone.utc)
queue = _load_queue()
due = []
for entry in queue:
scheduled = entry.get("scheduled_at")
if not scheduled:
continue
scheduled_dt = datetime.fromisoformat(scheduled)
if scheduled_dt <= now:
due.append(entry)
return due
# ─── Slots management ────────────────────────────────────────────────────────
def get_slots() -> list[dict]:
return _load_slots()
def save_slots(slots: list[dict]) -> list[dict]:
"""Save custom posting slots."""
_save_slots(slots)
assign_queue_slots() # Recalculate queue
return slots
def reset_slots_to_default() -> list[dict]:
_save_slots(DEFAULT_SLOTS)
assign_queue_slots()
return DEFAULT_SLOTS
# ─── Calendar view ────────────────────────────────────────────────────────────
def get_calendar_week(week_offset: int = 0) -> dict:
"""
Lấy data cho calendar view theo tuần.
week_offset=0 → tuần này, 1 → tuần sau, -1 → tuần trước.
"""
today = datetime.now(timezone.utc)
monday = today - timedelta(days=today.weekday()) + timedelta(weeks=week_offset)
days = []
queue = _load_queue()
content_by_date: dict[str, list] = {}
for entry in queue:
scheduled = entry.get("scheduled_at")
if scheduled:
dt = datetime.fromisoformat(scheduled)
date_key = dt.strftime("%Y-%m-%d")
content_by_date.setdefault(date_key, []).append(entry)
day_names = ["Thứ 2", "Thứ 3", "Thứ 4", "Thứ 5", "Thứ 6", "Thứ 7", "CN"]
for i in range(7):
day = monday + timedelta(days=i)
date_key = day.strftime("%Y-%m-%d")
days.append({
"date": date_key,
"day_name": day_names[i],
"is_today": date_key == today.strftime("%Y-%m-%d"),
"entries": content_by_date.get(date_key, []),
})
return {
"week_start": monday.strftime("%Y-%m-%d"),
"week_offset": week_offset,
"days": days,
}
"""Social providers package."""
from .base import SocialProvider, APIError, RateLimitError
from .facebook import FacebookProvider, InstagramProvider
from .tiktok import TikTokProvider
__all__ = [
"SocialProvider",
"APIError",
"RateLimitError",
"FacebookProvider",
"InstagramProvider",
"TikTokProvider",
]
"""
Social Provider Base Class
Ported & simplified từ BrightBean Studio providers/base.py
Bỏ Django dependency, chuyển sang httpx async cho FastAPI
"""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from typing import Any
import httpx
logger = logging.getLogger(__name__)
REQUEST_TIMEOUT = 30.0
class APIError(Exception):
def __init__(self, message: str, status_code: int = 0, platform: str = "", raw_response: dict | None = None):
super().__init__(message)
self.status_code = status_code
self.platform = platform
self.raw_response = raw_response or {}
class RateLimitError(APIError):
def __init__(self, message: str, retry_after: int | None = None, **kwargs):
super().__init__(message, **kwargs)
self.retry_after = retry_after
class SocialProvider(ABC):
"""
Abstract base class cho tất cả social platform providers.
Ported từ BrightBean Studio — simplified cho Canifa use case.
Mục đích: publish outfit suggestions + thu thập social feedback.
"""
def __init__(self, credentials: dict | None = None):
self.credentials = credentials or {}
@property
@abstractmethod
def platform_name(self) -> str:
"""Tên platform (vd: 'Facebook', 'Instagram', 'TikTok')."""
@abstractmethod
def get_profile(self, access_token: str) -> dict:
"""Lấy thông tin tài khoản đã kết nối."""
@abstractmethod
def publish_post(self, access_token: str, content: dict) -> dict:
"""
Publish nội dung lên platform.
content = {
"text": str,
"image_url": str | None,
"link": str | None,
}
Returns: {"post_id": str, "url": str, "success": bool}
"""
# ─────────────────────────────────────────
# Optional methods (override khi cần)
# ─────────────────────────────────────────
def get_comments(self, access_token: str, post_id: str) -> list[dict]:
"""Lấy comments của một post. Override theo từng platform."""
raise NotImplementedError(f"{self.platform_name} chưa hỗ trợ get_comments")
def reply_comment(self, access_token: str, comment_id: str, text: str) -> dict:
"""Reply comment. Override theo từng platform."""
raise NotImplementedError(f"{self.platform_name} chưa hỗ trợ reply_comment")
def validate_token(self, access_token: str) -> bool:
"""Kiểm tra token còn hiệu lực không."""
try:
self.get_profile(access_token)
return True
except Exception:
return False
# ─────────────────────────────────────────
# HTTP helper (sync — dùng cho background worker)
# ─────────────────────────────────────────
def _request(
self,
method: str,
url: str,
*,
access_token: str | None = None,
headers: dict | None = None,
params: dict | None = None,
json: dict | None = None,
data: Any = None,
files: dict | None = None,
timeout: float = REQUEST_TIMEOUT,
) -> httpx.Response:
"""HTTP request với error handling chuẩn. Raises APIError / RateLimitError."""
req_headers = {}
if access_token:
req_headers["Authorization"] = f"Bearer {access_token}"
if headers:
req_headers.update(headers)
request_kwargs: dict = {
"headers": req_headers,
"params": params,
"json": json,
"files": files,
}
if isinstance(data, bytes):
request_kwargs["content"] = data
else:
request_kwargs["data"] = data
with httpx.Client(timeout=timeout) as client:
response = client.request(method, url, **request_kwargs)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
raise RateLimitError(
f"Rate limit {self.platform_name}: {response.text[:300]}",
retry_after=int(retry_after) if retry_after else None,
platform=self.platform_name,
raw_response=self._safe_json(response),
)
if response.status_code >= 400:
raise APIError(
f"{self.platform_name} API {response.status_code}: {response.text[:300]}",
status_code=response.status_code,
platform=self.platform_name,
raw_response=self._safe_json(response),
)
return response
@staticmethod
def _safe_json(response: httpx.Response) -> dict:
try:
return response.json()
except Exception:
return {}
"""
Facebook & Instagram Provider
Ported & adapted từ BrightBean Studio providers/facebook.py + providers/instagram.py
Simplified: chỉ giữ phần publish post + get comments (Canifa use case)
"""
from __future__ import annotations
import logging
import os
from .base import SocialProvider, APIError
logger = logging.getLogger(__name__)
FACEBOOK_GRAPH_API = "https://graph.facebook.com/v19.0"
# App credentials từ env
FACEBOOK_APP_ID = os.getenv("FACEBOOK_APP_ID", "")
FACEBOOK_APP_SECRET = os.getenv("FACEBOOK_APP_SECRET", "")
class FacebookProvider(SocialProvider):
"""
Facebook Page provider — publish posts + đọc comments.
Dùng cho: Canifa auto-post outfit suggestions lên Facebook Page.
"""
@property
def platform_name(self) -> str:
return "Facebook"
def get_profile(self, access_token: str) -> dict:
"""Lấy thông tin Facebook Page."""
response = self._request(
"GET",
f"{FACEBOOK_GRAPH_API}/me",
access_token=access_token,
params={"fields": "id,name,fan_count,picture"},
)
return response.json()
def publish_post(self, access_token: str, content: dict) -> dict:
"""
Publish một post lên Facebook Page.
content = {"text": str, "link": str | None, "image_url": str | None}
"""
page_id = content.get("page_id", "me")
payload: dict = {"message": content.get("text", "")}
if content.get("link"):
payload["link"] = content["link"]
# Nếu có ảnh → dùng photos endpoint
if content.get("image_url"):
return self._publish_with_photo(access_token, page_id, content)
response = self._request(
"POST",
f"{FACEBOOK_GRAPH_API}/{page_id}/feed",
access_token=access_token,
json=payload,
)
data = response.json()
return {
"post_id": data.get("id", ""),
"success": True,
"platform": "facebook",
}
def _publish_with_photo(self, access_token: str, page_id: str, content: dict) -> dict:
"""Upload ảnh + publish caption lên Facebook."""
payload = {
"url": content["image_url"],
"caption": content.get("text", ""),
"published": True,
}
response = self._request(
"POST",
f"{FACEBOOK_GRAPH_API}/{page_id}/photos",
access_token=access_token,
json=payload,
)
data = response.json()
return {
"post_id": data.get("id", ""),
"success": True,
"platform": "facebook",
}
def get_comments(self, access_token: str, post_id: str) -> list[dict]:
"""Lấy comments của một Facebook post để phân tích feedback."""
response = self._request(
"GET",
f"{FACEBOOK_GRAPH_API}/{post_id}/comments",
access_token=access_token,
params={
"fields": "id,message,from,created_time,like_count",
"order": "chronological",
"limit": 100,
},
)
data = response.json()
comments = []
for item in data.get("data", []):
comments.append({
"platform_comment_id": item.get("id", ""),
"body": item.get("message", ""),
"sender_name": item.get("from", {}).get("name", "Unknown"),
"sender_id": item.get("from", {}).get("id", ""),
"created_at": item.get("created_time", ""),
"like_count": item.get("like_count", 0),
})
return comments
def reply_comment(self, access_token: str, comment_id: str, text: str) -> dict:
"""Reply vào một Facebook comment."""
response = self._request(
"POST",
f"{FACEBOOK_GRAPH_API}/{comment_id}/comments",
access_token=access_token,
json={"message": text},
)
data = response.json()
return {"reply_id": data.get("id", ""), "success": True}
class InstagramProvider(SocialProvider):
"""
Instagram Business Account provider.
Dùng cho: Canifa auto-post outfit / fashion content lên Instagram.
"""
@property
def platform_name(self) -> str:
return "Instagram"
def get_profile(self, access_token: str) -> dict:
"""Lấy thông tin Instagram Business Account."""
response = self._request(
"GET",
f"{FACEBOOK_GRAPH_API}/me",
access_token=access_token,
params={
"fields": "id,name,username,biography,followers_count,media_count,profile_picture_url",
},
)
return response.json()
def publish_post(self, access_token: str, content: dict) -> dict:
"""
Publish Instagram post (ảnh + caption).
Instagram requires media — must have image_url.
content = {"text": str, "image_url": str (required), "ig_user_id": str}
"""
ig_user_id = content.get("ig_user_id", "me")
image_url = content.get("image_url")
if not image_url:
raise ValueError("Instagram publish requires image_url")
# Step 1: Tạo media container
container_response = self._request(
"POST",
f"{FACEBOOK_GRAPH_API}/{ig_user_id}/media",
access_token=access_token,
json={
"image_url": image_url,
"caption": content.get("text", ""),
},
)
container_id = container_response.json().get("id")
if not container_id:
raise APIError("Không tạo được Instagram media container", platform="Instagram")
# Step 2: Publish container
publish_response = self._request(
"POST",
f"{FACEBOOK_GRAPH_API}/{ig_user_id}/media_publish",
access_token=access_token,
json={"creation_id": container_id},
)
data = publish_response.json()
return {
"post_id": data.get("id", ""),
"success": True,
"platform": "instagram",
}
def get_comments(self, access_token: str, post_id: str) -> list[dict]:
"""Lấy comments của một Instagram post."""
response = self._request(
"GET",
f"{FACEBOOK_GRAPH_API}/{post_id}/comments",
access_token=access_token,
params={
"fields": "id,text,username,timestamp,like_count",
"limit": 100,
},
)
data = response.json()
comments = []
for item in data.get("data", []):
comments.append({
"platform_comment_id": item.get("id", ""),
"body": item.get("text", ""),
"sender_name": item.get("username", "Unknown"),
"sender_id": item.get("username", ""),
"created_at": item.get("timestamp", ""),
"like_count": item.get("like_count", 0),
})
return comments
"""
TikTok Provider
Ported & adapted từ BrightBean Studio providers/tiktok.py
Simplified cho Canifa use case: publish fashion video + đọc comments
"""
from __future__ import annotations
import logging
import os
from .base import SocialProvider, APIError
logger = logging.getLogger(__name__)
TIKTOK_API_BASE = "https://open.tiktokapis.com/v2"
TIKTOK_CLIENT_KEY = os.getenv("TIKTOK_CLIENT_KEY", "")
TIKTOK_CLIENT_SECRET = os.getenv("TIKTOK_CLIENT_SECRET", "")
class TikTokProvider(SocialProvider):
"""
TikTok provider — publish video + đọc comments.
Dùng cho: Canifa auto-post fashion reels/videos lên TikTok.
"""
@property
def platform_name(self) -> str:
return "TikTok"
def get_profile(self, access_token: str) -> dict:
"""Lấy thông tin TikTok user."""
response = self._request(
"GET",
f"{TIKTOK_API_BASE}/user/info/",
access_token=access_token,
params={
"fields": "open_id,union_id,display_name,avatar_url,follower_count,following_count,likes_count",
},
)
return response.json().get("data", {}).get("user", {})
def publish_post(self, access_token: str, content: dict) -> dict:
"""
Publish TikTok video.
TikTok cần video_url và dùng Content Posting API.
content = {
"title": str, ← caption/title
"video_url": str, ← direct link tới video file
"privacy": str, ← "PUBLIC" | "FRIENDS" | "PRIVATE"
}
"""
# TikTok direct post via URL
payload = {
"post_info": {
"title": content.get("title", ""),
"privacy_level": content.get("privacy", "PUBLIC_TO_EVERYONE"),
"disable_duet": False,
"disable_comment": False,
"disable_stitch": False,
},
"source_info": {
"source": "PULL_FROM_URL",
"video_url": content.get("video_url", ""),
},
}
response = self._request(
"POST",
f"{TIKTOK_API_BASE}/post/publish/video/init/",
access_token=access_token,
json=payload,
)
data = response.json().get("data", {})
return {
"post_id": data.get("publish_id", ""),
"success": True,
"platform": "tiktok",
}
def get_comments(self, access_token: str, post_id: str) -> list[dict]:
"""Lấy comments của một TikTok video."""
response = self._request(
"POST",
f"{TIKTOK_API_BASE}/video/comment/list/",
access_token=access_token,
json={
"video_id": post_id,
"max_count": 100,
},
)
data = response.json().get("data", {})
comments = []
for item in data.get("comments", []):
comments.append({
"platform_comment_id": item.get("id", ""),
"body": item.get("text", ""),
"sender_name": item.get("display_name", "Unknown"),
"sender_id": item.get("open_id", ""),
"created_at": str(item.get("create_time", "")),
"like_count": item.get("like_count", 0),
})
return comments
"""
Social Media Publish Engine
Ported & adapted từ BrightBean Studio apps/publisher/engine.py
Chuyển từ Django + background_task → pure Python + asyncio
Core loop:
1. Poll queue cho entries có scheduled_at <= now()
2. Dispatch publish lên từng platform (parallel threads)
3. Exponential backoff retry: 1min → 5min → 30min
4. Mark published → remove từ queue
5. Feed kết quả vào notification engine
"""
import asyncio
import logging
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta, timezone
from typing import Optional
logger = logging.getLogger(__name__)
# ─── Config ──────────────────────────────────────────────────────────────────
RETRY_BACKOFF_SECONDS = [60, 300, 1800] # 1min, 5min, 30min
MAX_RETRIES = 3
MAX_CONCURRENT_POSTS = 4
POLL_INTERVAL_SECONDS = int(os.getenv("PUBLISH_POLL_INTERVAL", "30"))
# In-memory retry tracker: content_id → {retry_count, next_retry_at}
_retry_state: dict[str, dict] = {}
class PublishEngine:
"""
Orchestrates publishing of scheduled social media content.
Ported từ BrightBean PublishEngine (publisher/engine.py).
"""
def __init__(self):
self._running = False
# ─── Main poll loop ───────────────────────────────────────────────────────
def poll_and_publish(self) -> int:
"""
Main poll tick — tìm content đã đến giờ → publish.
Gọi theo timer (via APScheduler hoặc background thread).
Returns: số content đã publish thành công.
"""
from common.social.post_queue import get_due_for_publish, remove_from_queue
from common.social.approval_gate import mark_published, mark_failed, get_content
due_entries = get_due_for_publish()
# Lọc bỏ entries đang trong retry wait
now = datetime.now(timezone.utc)
due_entries = [
e for e in due_entries
if e["content_id"] not in _retry_state
or datetime.fromisoformat(_retry_state[e["content_id"]]["next_retry_at"]) <= now
]
if not due_entries:
return 0
logger.info("🔔 Publish engine: %d posts due", len(due_entries))
published_count = 0
with ThreadPoolExecutor(max_workers=min(len(due_entries), MAX_CONCURRENT_POSTS)) as executor:
futures = {
executor.submit(self._publish_entry, entry): entry
for entry in due_entries
}
for future in as_completed(futures):
entry = futures[future]
content_id = entry["content_id"]
try:
result = future.result()
if result.get("success"):
content = get_content(content_id)
mark_published(content_id, result.get("platform_results", {}))
remove_from_queue(content_id)
if content_id in _retry_state:
del _retry_state[content_id]
published_count += 1
logger.info("✅ Published: %s", content_id)
else:
self._schedule_retry(content_id, result.get("error", "unknown error"))
except Exception as e:
logger.exception("Unexpected publish error for %s", content_id)
self._schedule_retry(content_id, str(e))
return published_count
# ─── Publish single content to all platforms ──────────────────────────────
def _publish_entry(self, entry: dict) -> dict:
"""
Publish một queue entry lên tất cả platforms đã chọn.
Returns: {"success": bool, "platform_results": dict, "error": str}
"""
from common.social.approval_gate import get_content
content_id = entry["content_id"]
content = get_content(content_id)
if not content:
return {"success": False, "error": f"Content {content_id} not found"}
if content.get("status") not in ("approved", "scheduled"):
return {"success": False, "error": f"Status invalid: {content.get('status')}"}
platforms = content.get("platforms", [])
if not platforms:
return {"success": False, "error": "No platforms configured"}
platform_results = {}
all_success = True
errors = []
# Publish tới từng platform parallel
with ThreadPoolExecutor(max_workers=min(len(platforms), 3)) as executor:
futures = {
executor.submit(self._publish_to_platform, content, platform): platform
for platform in platforms
}
for future in as_completed(futures):
platform = futures[future]
try:
result = future.result()
platform_results[platform] = result
if not result.get("success"):
all_success = False
errors.append(f"{platform}: {result.get('error', 'unknown')}")
except Exception as e:
platform_results[platform] = {"success": False, "error": str(e)}
all_success = False
errors.append(f"{platform}: {e}")
return {
"success": all_success,
"platform_results": platform_results,
"error": "; ".join(errors) if errors else "",
}
def _publish_to_platform(self, content: dict, platform: str) -> dict:
"""
Publish content lên một platform cụ thể.
Lấy credentials từ env, gọi provider tương ứng.
"""
start = time.monotonic()
try:
provider = self._get_provider(platform)
access_token = self._get_token(platform)
if not access_token:
return {
"success": False,
"error": f"Không có access token cho {platform}. Set env: {platform.upper()}_ACCESS_TOKEN",
}
publish_content = {
"text": content.get("caption", ""),
"image_url": content.get("image_urls", [None])[0],
"link": content.get("metadata", {}).get("link"),
"page_id": os.getenv("FACEBOOK_PAGE_ID", "me"),
"ig_user_id": os.getenv("INSTAGRAM_BUSINESS_ID", "me"),
"title": content.get("title", ""),
"privacy": "PUBLIC_TO_EVERYONE",
}
result = provider.publish_post(access_token, publish_content)
duration_ms = int((time.monotonic() - start) * 1000)
logger.info(
"✅ Published to %s in %dms: %s",
platform, duration_ms, result.get("post_id", ""),
)
return {**result, "success": True, "duration_ms": duration_ms}
except Exception as e:
duration_ms = int((time.monotonic() - start) * 1000)
logger.error("❌ Publish to %s failed (%dms): %s", platform, duration_ms, e)
return {"success": False, "error": str(e), "duration_ms": duration_ms}
def _get_provider(self, platform: str):
"""Instantiate platform provider."""
from common.social.providers import (
FacebookProvider, InstagramProvider, TikTokProvider
)
mapping = {
"facebook": FacebookProvider,
"instagram": InstagramProvider,
"tiktok": TikTokProvider,
}
cls = mapping.get(platform)
if not cls:
raise ValueError(f"Unsupported platform: {platform}")
return cls()
def _get_token(self, platform: str) -> str | None:
"""Lấy access token từ env."""
env_map = {
"facebook": "FACEBOOK_PAGE_ACCESS_TOKEN",
"instagram": "INSTAGRAM_ACCESS_TOKEN",
"tiktok": "TIKTOK_ACCESS_TOKEN",
}
return os.getenv(env_map.get(platform, ""), "")
# ─── Retry logic ─────────────────────────────────────────────────────────
def _schedule_retry(self, content_id: str, error: str):
"""Exponential backoff retry từ BrightBean."""
from common.social.approval_gate import mark_failed
state = _retry_state.get(content_id, {"retry_count": 0})
retry_count = state["retry_count"]
if retry_count >= MAX_RETRIES:
logger.warning("❌ Max retries exceeded for %s: %s", content_id, error)
mark_failed(content_id, f"Failed after {MAX_RETRIES} retries: {error}")
if content_id in _retry_state:
del _retry_state[content_id]
return
backoff = RETRY_BACKOFF_SECONDS[min(retry_count, len(RETRY_BACKOFF_SECONDS) - 1)]
next_retry = (datetime.now(timezone.utc) + timedelta(seconds=backoff)).isoformat()
_retry_state[content_id] = {
"retry_count": retry_count + 1,
"next_retry_at": next_retry,
"last_error": error,
}
logger.info(
"🔄 Retry %d/%d scheduled for %s in %ds",
retry_count + 1, MAX_RETRIES, content_id, backoff,
)
# ─── Background runner ────────────────────────────────────────────────────────
_engine = PublishEngine()
_bg_task: asyncio.Task | None = None
async def _publish_loop():
"""Background async loop — poll every POLL_INTERVAL_SECONDS."""
logger.info("🚀 Publish engine started (interval: %ds)", POLL_INTERVAL_SECONDS)
while True:
try:
loop = asyncio.get_event_loop()
count = await loop.run_in_executor(None, _engine.poll_and_publish)
if count > 0:
logger.info("📤 Published %d posts this tick", count)
except Exception as e:
logger.error("Publish loop error: %s", e)
await asyncio.sleep(POLL_INTERVAL_SECONDS)
def start_publish_engine(app=None):
"""
Khởi động publish engine background loop.
Gọi từ FastAPI startup event (server.py).
"""
global _bg_task
async def _start():
global _bg_task
_bg_task = asyncio.create_task(_publish_loop())
logger.info("✅ Publish engine background task started")
if app:
@app.on_event("startup")
async def startup():
await _start()
else:
loop = asyncio.get_event_loop()
loop.create_task(_publish_loop())
def get_retry_state() -> dict:
"""Expose retry state cho monitoring/dashboard."""
return dict(_retry_state)
def manual_publish(content_id: str) -> dict:
"""
Trigger publish ngay lập tức (không qua queue timing).
Dùng cho "Publish Now" button trên dashboard.
"""
from common.social.approval_gate import get_content, mark_failed, mark_published
from common.social.post_queue import remove_from_queue
content = get_content(content_id)
if not content:
return {"success": False, "error": "Content not found"}
if content.get("status") not in ("approved", "scheduled", "draft"):
return {"success": False, "error": f"Cannot publish with status: {content.get('status')}"}
entry = {"content_id": content_id}
result = _engine._publish_entry(entry)
if result.get("success"):
mark_published(content_id, result.get("platform_results", {}))
remove_from_queue(content_id)
else:
mark_failed(content_id, result.get("error", "Manual publish failed"))
return result
"""
Sentiment Analysis Engine — Tiếng Việt + Tiếng Anh
Ported & extended từ BrightBean Studio apps/inbox/sentiment.py
Thêm: keywords tiếng Việt fashion/thương mại điện tử cho Canifa use case
"""
import re
# ─────────────────────────────────────────────
# ENGLISH Keywords (từ BrightBean Studio)
# ─────────────────────────────────────────────
_POSITIVE_EN = {
"love", "great", "amazing", "thank", "thanks", "excellent", "awesome",
"perfect", "best", "fantastic", "wonderful", "beautiful", "brilliant",
"incredible", "outstanding", "impressive", "helpful", "appreciate",
"happy", "glad", "excited", "recommend", "superb", "delighted", "thrilled",
"good", "nice", "cute", "pretty", "quality", "satisfied", "worth",
}
_NEGATIVE_EN = {
"hate", "terrible", "awful", "worst", "disappointed", "broken", "scam",
"refund", "horrible", "disgusting", "pathetic", "useless", "angry",
"furious", "unacceptable", "frustrating", "annoying", "poor", "waste",
"trash", "spam", "fake", "misleading", "rude", "unprofessional",
"cheap", "bad", "ugly", "tight", "loose", "wrong", "return", "complaint",
}
# ─────────────────────────────────────────────
# VIETNAMESE Keywords — Thêm mới cho Canifa
# ─────────────────────────────────────────────
_POSITIVE_VI = {
# Khen ngợi chung
"tuyệt", "tuyệt vời", "xuất sắc", "hoàn hảo", "đẹp", "đẹp lắm",
"đẹp quá", "xinh", "xinh lắm", "xinh xắn", "dễ thương", "sang",
"sang trọng", "chất", "chất lắm", "hài lòng", "ưng", "ưng lắm",
"thích", "thích lắm", "thích quá", "yêu", "mê", "ok", "ổn",
# Chất lượng sản phẩm
"chất lượng", "mịn", "mềm", "nhẹ", "thoáng", "thoải mái", "vừa vặn",
"đúng size", "chuẩn", "bền", "đúng màu", "đúng mô tả", "y hình",
# Giao hàng / Dịch vụ
"nhanh", "giao nhanh", "đóng gói đẹp", "cẩn thận", "nhiệt tình",
"tư vấn tốt", "phục vụ tốt", "hỗ trợ nhiệt tình",
# Sẽ mua lại
"sẽ mua lại", "ủng hộ tiếp", "mua lần 2", "recommend", "giới thiệu",
"đáng mua", "đáng tiền", "xứng đáng", "worth it",
# Canifa specific
"canifa ngon", "canifa tốt", "thương hiệu uy tín",
"5 sao", "5⭐", "⭐⭐⭐⭐⭐", "👍", "❤️", "🔥",
}
_NEGATIVE_VI = {
# Chê về sản phẩm
"xấu", "kém", "tệ", "không đẹp", "không ưng", "thất vọng",
"chán", "nhạt", "nhàm", "mình không thích", "không như mô tả",
"khác hình", "màu lệch", "phai màu", "nhăn nhúm", "lỗi", "lỗi hàng",
"đường may xấu", "lỏng chỉ", "bung chỉ",
# Size / Fit
"không vừa", "sai size", "nhỏ hơn", "lớn hơn", "chật", "rộng thùng thình",
"ngắn quá", "dài quá", "bé quá",
# Giao hàng / Dịch vụ
"giao chậm", "giao trễ", "sai hàng", "thiếu hàng", "đóng gói ẩu",
"bị bẩn", "phục vụ tệ", "nhân viên thái độ", "không hỗ trợ",
"không phản hồi", "lờ đi", "im hơi lặng tiếng",
# Trả hàng / Hoàn tiền
"trả hàng", "đổi hàng", "hoàn tiền", "khiếu nại", "phàn nàn",
"tố cáo", "scam", "lừa đảo", "hàng giả",
# Canifa specific
"canifa tệ", "không mua nữa", "lần cuối", "thất vọng với canifa",
"1 sao", "1⭐", "👎", "😡", "😤", "😠",
}
# ─────────────────────────────────────────────
# Core analyzer
# ─────────────────────────────────────────────
def _tokenize(text: str) -> set[str]:
"""Lowercase và tokenize text, giữ lại cả nguyên câu (cho multi-word keywords)."""
text_lower = text.lower().strip()
# Word tokens (strip punctuation)
words = set(re.sub(r"[^\w\s]", " ", text_lower).split())
# Thêm nguyên text để match multi-word phrases
words.add(text_lower)
return words
def analyze_sentiment(text: str) -> str:
"""
Phân tích sentiment của text (Tiếng Việt + Tiếng Anh).
Returns:
'positive' | 'negative' | 'neutral'
"""
if not text or not text.strip():
return "neutral"
tokens = _tokenize(text)
text_lower = text.lower()
# Score tiếng Anh
pos_en = sum(1 for kw in _POSITIVE_EN if kw in tokens)
neg_en = sum(1 for kw in _NEGATIVE_EN if kw in tokens)
# Score tiếng Việt (check phrase match trong full text)
pos_vi = sum(1 for kw in _POSITIVE_VI if kw in text_lower)
neg_vi = sum(1 for kw in _NEGATIVE_VI if kw in text_lower)
pos_total = pos_en + pos_vi
neg_total = neg_en + neg_vi
if neg_total > pos_total:
return "negative"
elif pos_total > neg_total:
return "positive"
return "neutral"
def analyze_sentiment_detail(text: str) -> dict:
"""
Phân tích chi tiết — trả về score + label + matched keywords.
Dùng cho debug / dashboard.
"""
if not text or not text.strip():
return {"label": "neutral", "pos_score": 0, "neg_score": 0, "matched": []}
tokens = _tokenize(text)
text_lower = text.lower()
matched_pos = [kw for kw in (_POSITIVE_EN | _POSITIVE_VI) if kw in tokens or kw in text_lower]
matched_neg = [kw for kw in (_NEGATIVE_EN | _NEGATIVE_VI) if kw in tokens or kw in text_lower]
pos_score = len(matched_pos)
neg_score = len(matched_neg)
if neg_score > pos_score:
label = "negative"
elif pos_score > neg_score:
label = "positive"
else:
label = "neutral"
return {
"label": label,
"pos_score": pos_score,
"neg_score": neg_score,
"matched_positive": matched_pos[:5], # Top 5 để không spam
"matched_negative": matched_neg[:5],
}
......@@ -214,14 +214,42 @@ app.include_router(mock_auth_router) # Mock Auth (identity linking test)
from api.feedback_agent_route import router as feedback_agent_router
app.include_router(feedback_agent_router) # Lõi Agent Rút Kinh Nghiệm (Langfuse -> Rules)
from api.social_inbox_route import router as social_inbox_router
app.include_router(social_inbox_router) # Social Inbox (Facebook/Instagram/TikTok → Learning Loop)
# ─── Phase 2: AI Content Pipeline ───────────────────────────────────────────
from api.notification_route import router as notification_router
app.include_router(notification_router) # In-app + Email + Webhook + Slack notifications
from api.content_approval_route import router as content_approval_router
app.include_router(content_approval_router) # Content approval gate (draft → review → publish)
from api.queue_route import router as queue_router
app.include_router(queue_router) # Post queue + Calendar scheduling
from api.media_route import router as media_router
app.include_router(media_router) # Media library (upload/resize/serve)
from api.templates_route import router as templates_router
app.include_router(templates_router) # Content templates + RSS feeds (ported from BrightBean)
# ─── Start publish engine background loop ───────────────────────────────────
from common.social.scheduler import start_publish_engine
start_publish_engine(app) # Auto-publish scheduled content every 30s
if __name__ == "__main__":
print("=" * 60)
print("🚀 Contract AI Service Starting...")
print("=" * 60)
print(f"📡 REST API: http://localhost:{PORT}")
print(f"📡 Test Chatbot: http://localhost:{PORT}/static/index.html")
print(f"📚 API Docs: http://localhost:{PORT}/docs")
print(f"📡 Test Chatbot: http://localhost:{PORT}/static/index.html")
print(f"📚 API Docs: http://localhost:{PORT}/docs")
print(f"📋 Approval: http://localhost:{PORT}/static/content-approval/index.html")
print(f"📅 Calendar: http://localhost:{PORT}/static/content-calendar/index.html")
print(f"🖼️ Media Library: http://localhost:{PORT}/static/media-library/index.html")
print(f"📬 Social Inbox: http://localhost:{PORT}/static/social-inbox/index.html")
print(f"✍️ Composer: http://localhost:{PORT}/static/content-composer/index.html")
print("=" * 60)
ENABLE_RELOAD = False
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Canifa — Content Approval Queue</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
--bg: #0d0d14; --surface: #13131f; --card: #1a1a2e;
--border: #2a2a40; --primary: #6366f1; --primary-light: #818cf8;
--green: #10b981; --yellow: #f59e0b; --red: #ef4444; --blue: #3b82f6;
--text: #e2e8f0; --muted: #64748b;
--draft: #475569; --pending: #f59e0b; --approved: #10b981;
--rejected: #ef4444; --scheduled: #3b82f6; --published: #8b5cf6;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
header {
background: linear-gradient(135deg, #1e1b4b 0%, #13131f 100%);
border-bottom: 1px solid var(--border);
padding: 16px 28px;
display: flex; align-items: center; justify-content: space-between;
}
.logo { display: flex; align-items: center; gap: 12px; }
.logo-icon { width: 36px; height: 36px; background: linear-gradient(135deg, var(--primary), #a855f7); border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 18px; }
h1 { font-size: 18px; font-weight: 700; }
.header-actions { display: flex; gap: 8px; }
.btn {
padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
cursor: pointer; border: none; transition: all .2s; font-family: inherit;
}
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-light); }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--muted); }
.btn-ghost:hover { border-color: var(--primary); color: var(--text); }
.btn-sm { padding: 5px 10px; font-size: 12px; }
.btn-green { background: var(--green); color: white; }
.btn-green:hover { opacity: .85; }
.btn-red { background: var(--red); color: white; }
.btn-red:hover { opacity: .85; }
.btn-yellow { background: var(--yellow); color: #1a1a2e; }
main { padding: 24px 28px; max-width: 1400px; margin: 0 auto; }
/* Stats */
.stats-row { display: flex; gap: 16px; margin-bottom: 24px; flex-wrap: wrap; }
.stat-card {
flex: 1; min-width: 140px; background: var(--card);
border: 1px solid var(--border); border-radius: 12px;
padding: 16px; text-align: center;
}
.stat-number { font-size: 28px; font-weight: 700; margin-bottom: 4px; }
.stat-label { font-size: 12px; color: var(--muted); }
/* Filters */
.filters { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.filter-btn {
padding: 6px 14px; border-radius: 20px; font-size: 12px; font-weight: 500;
cursor: pointer; border: 1px solid var(--border); background: transparent;
color: var(--muted); transition: all .2s; font-family: inherit;
}
.filter-btn.active, .filter-btn:hover {
background: var(--primary); border-color: var(--primary); color: white;
}
/* Content grid */
.content-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 16px; }
.content-card {
background: var(--card); border: 1px solid var(--border); border-radius: 14px;
overflow: hidden; transition: box-shadow .2s;
}
.content-card:hover { box-shadow: 0 0 20px rgba(99,102,241,.15); }
.card-status-bar { height: 3px; }
.card-body { padding: 16px; }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
.content-title { font-weight: 600; font-size: 14px; line-height: 1.4; margin-bottom: 4px; }
.content-meta { font-size: 11px; color: var(--muted); }
.status-badge {
padding: 3px 8px; border-radius: 20px; font-size: 11px; font-weight: 600;
white-space: nowrap; flex-shrink: 0;
}
.content-caption {
font-size: 13px; color: #94a3b8; margin: 10px 0;
line-height: 1.5; max-height: 60px; overflow: hidden;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
}
.platforms { display: flex; gap: 6px; margin: 10px 0; flex-wrap: wrap; }
.platform-tag {
padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;
background: rgba(99,102,241,.15); color: var(--primary-light);
}
.card-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
.review-comment {
background: rgba(239,68,68,.08); border: 1px solid rgba(239,68,68,.2);
border-radius: 6px; padding: 8px; font-size: 12px; color: #fca5a5;
margin: 8px 0;
}
.review-comment.success {
background: rgba(16,185,129,.08); border-color: rgba(16,185,129,.2); color: #6ee7b7;
}
/* Modal */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.7);
display: flex; align-items: center; justify-content: center; z-index: 100;
display: none;
}
.modal-overlay.show { display: flex; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 24px; width: 420px; max-width: 90vw; }
.modal h3 { font-size: 16px; font-weight: 700; margin-bottom: 16px; }
textarea { width: 100%; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 8px; padding: 10px; font-family: inherit; font-size: 13px; resize: vertical; min-height: 80px; margin-bottom: 12px; }
textarea:focus { outline: none; border-color: var(--primary); }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
/* Create modal */
input[type=text], select {
width: 100%; background: var(--bg); border: 1px solid var(--border);
color: var(--text); border-radius: 8px; padding: 9px 12px;
font-family: inherit; font-size: 13px; margin-bottom: 10px;
}
input:focus, select:focus { outline: none; border-color: var(--primary); }
label { font-size: 12px; color: var(--muted); margin-bottom: 4px; display: block; }
.empty { text-align: center; padding: 80px 20px; color: var(--muted); }
.empty-icon { font-size: 48px; margin-bottom: 12px; }
.loader { text-align: center; padding: 40px; color: var(--muted); }
</style>
</head>
<body>
<header>
<div class="logo">
<div class="logo-icon">📋</div>
<div>
<h1>Content Approval Queue</h1>
<div style="font-size:12px;color:var(--muted)">Canifa AI Content Pipeline</div>
</div>
</div>
<div class="header-actions">
<button class="btn btn-ghost btn-sm" onclick="refresh()">🔄 Refresh</button>
<button class="btn btn-primary" onclick="openCreate()">✨ Tạo Content</button>
</div>
</header>
<main>
<!-- Stats -->
<div class="stats-row" id="statsRow">
<div class="stat-card"><div class="stat-number" id="statTotal">—</div><div class="stat-label">Tổng</div></div>
<div class="stat-card"><div class="stat-number" style="color:var(--yellow)" id="statPending">—</div><div class="stat-label">Chờ duyệt</div></div>
<div class="stat-card"><div class="stat-number" style="color:var(--green)" id="statApproved">—</div><div class="stat-label">Đã duyệt</div></div>
<div class="stat-card"><div class="stat-number" style="color:var(--blue)" id="statScheduled">—</div><div class="stat-label">Đã lên lịch</div></div>
<div class="stat-card"><div class="stat-number" style="color:#8b5cf6" id="statPublished">—</div><div class="stat-label">Đã đăng</div></div>
<div class="stat-card"><div class="stat-number" style="color:var(--red)" id="statRejected">—</div><div class="stat-label">Từ chối</div></div>
</div>
<!-- Filters -->
<div class="filters">
<button class="filter-btn active" onclick="setFilter('', this)">Tất cả</button>
<button class="filter-btn" onclick="setFilter('pending_review', this)">🔍 Chờ duyệt</button>
<button class="filter-btn" onclick="setFilter('draft', this)">📝 Nháp</button>
<button class="filter-btn" onclick="setFilter('approved', this)">✅ Đã duyệt</button>
<button class="filter-btn" onclick="setFilter('scheduled', this)">⏰ Lên lịch</button>
<button class="filter-btn" onclick="setFilter('published', this)">🚀 Đã đăng</button>
<button class="filter-btn" onclick="setFilter('rejected', this)">❌ Từ chối</button>
</div>
<!-- Content Grid -->
<div id="contentGrid">
<div class="loader">⏳ Đang tải...</div>
</div>
</main>
<!-- Review Modal -->
<div class="modal-overlay" id="reviewModal">
<div class="modal">
<h3 id="reviewTitle">Review Content</h3>
<textarea id="reviewComment" placeholder="Nhập ghi chú / lý do..."></textarea>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeModal('reviewModal')">Huỷ</button>
<button class="btn btn-green" id="confirmApproveBtn" onclick="confirmApprove()">✅ Approve</button>
<button class="btn btn-red" id="confirmRejectBtn" onclick="confirmReject()" style="display:none">❌ Từ chối</button>
<button class="btn btn-yellow" id="confirmChangesBtn" onclick="confirmChanges()" style="display:none">✏️ Yêu cầu sửa</button>
</div>
</div>
</div>
<!-- Create Modal -->
<div class="modal-overlay" id="createModal">
<div class="modal" style="width:500px">
<h3>✨ Tạo Content Mới</h3>
<label>Tiêu đề</label>
<input type="text" id="newTitle" placeholder="Ví dụ: Outfit hè 2025 - Set đồ đi biển"/>
<label>Caption</label>
<textarea id="newCaption" placeholder="Nội dung bài đăng..." style="margin-bottom:10px"></textarea>
<label>Platforms</label>
<div style="display:flex;gap:10px;margin-bottom:10px;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:4px;cursor:pointer"><input type="checkbox" id="fb" checked> Facebook</label>
<label style="display:flex;align-items:center;gap:4px;cursor:pointer"><input type="checkbox" id="ig" checked> Instagram</label>
<label style="display:flex;align-items:center;gap:4px;cursor:pointer"><input type="checkbox" id="tt"> TikTok</label>
</div>
<label>Tạo bởi</label>
<input type="text" id="newCreatedBy" value="ai_agent"/>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<input type="checkbox" id="autoSubmit">
<label style="margin:0;cursor:pointer" for="autoSubmit">Auto submit for review</label>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeModal('createModal')">Huỷ</button>
<button class="btn btn-primary" onclick="createContent()">Tạo Content</button>
</div>
</div>
</div>
<script>
const STATUS_COLORS = {
draft: '#475569', pending_review: '#f59e0b', changes_requested: '#f97316',
approved: '#10b981', rejected: '#ef4444', scheduled: '#3b82f6',
published: '#8b5cf6', cancelled: '#6b7280', failed: '#dc2626',
};
const STATUS_LABELS = {
draft: '📝 Nháp', pending_review: '🔍 Chờ duyệt',
changes_requested: '✏️ Cần sửa', rejected: '❌ Từ chối',
approved: '✅ Đã duyệt', scheduled: '⏰ Đã lên lịch',
published: '🚀 Đã đăng', cancelled: '🚫 Huỷ', failed: '💥 Thất bại',
};
let currentFilter = '';
let currentContentId = '';
let currentAction = '';
async function api(path, method = 'GET', body = null) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
const r = await fetch('/api/content' + path, opts);
return r.json();
}
async function refresh() {
await loadStats();
await loadContents();
}
async function loadStats() {
const data = await api('/stats');
document.getElementById('statTotal').textContent = data.total || 0;
const bs = data.by_status || {};
document.getElementById('statPending').textContent = bs.pending_review || 0;
document.getElementById('statApproved').textContent = bs.approved || 0;
document.getElementById('statScheduled').textContent = bs.scheduled || 0;
document.getElementById('statPublished').textContent = bs.published || 0;
document.getElementById('statRejected').textContent = bs.rejected || 0;
}
async function loadContents() {
const grid = document.getElementById('contentGrid');
const params = currentFilter ? `?status=${currentFilter}` : '';
const data = await api(params);
const contents = data.contents || [];
if (!contents.length) {
grid.innerHTML = '<div class="empty"><div class="empty-icon">📭</div><div>Không có content nào</div></div>';
return;
}
grid.innerHTML = '<div class="content-grid">' + contents.map(renderCard).join('') + '</div>';
}
function renderCard(c) {
const color = STATUS_COLORS[c.status] || '#475569';
const label = STATUS_LABELS[c.status] || c.status;
const platforms = (c.platforms || []).map(p => `<span class="platform-tag">${p}</span>`).join('');
const ts = c.created_at ? new Date(c.created_at).toLocaleDateString('vi-VN') : '';
const scheduled = c.scheduled_at ? `<div style="font-size:11px;color:var(--blue);margin-top:4px">⏰ ${new Date(c.scheduled_at).toLocaleString('vi-VN')}</div>` : '';
const comment = c.review_comment ? `<div class="review-comment ${c.status === 'approved' ? 'success' : ''}">${c.review_comment}</div>` : '';
const actions = buildActions(c);
return `
<div class="content-card">
<div class="card-status-bar" style="background:${color}"></div>
<div class="card-body">
<div class="card-header">
<div>
<div class="content-title">${c.title || '(Không tiêu đề)'}</div>
<div class="content-meta">bởi ${c.created_by || '?'} · ${ts}</div>
${scheduled}
</div>
<span class="status-badge" style="background:${color}22;color:${color}">${label}</span>
</div>
<div class="content-caption">${c.caption || ''}</div>
<div class="platforms">${platforms}</div>
${comment}
<div class="card-actions">${actions}</div>
</div>
</div>`;
}
function buildActions(c) {
const id = c.id;
let btns = '';
if (c.status === 'draft') btns += `<button class="btn btn-primary btn-sm" onclick="submitContent('${id}')">📤 Submit</button>`;
if (c.status === 'pending_review' || c.status === 'changes_requested') {
btns += `<button class="btn btn-green btn-sm" onclick="openReview('${id}','approve')">✅ Approve</button>`;
btns += `<button class="btn btn-yellow btn-sm" onclick="openReview('${id}','changes')">✏️ Sửa</button>`;
btns += `<button class="btn btn-red btn-sm" onclick="openReview('${id}','reject')">❌ Reject</button>`;
}
if (c.status === 'approved') {
btns += `<button class="btn btn-primary btn-sm" onclick="publishNow('${id}')">🚀 Publish Now</button>`;
}
if (c.status === 'failed' || c.status === 'rejected') {
btns += `<button class="btn btn-ghost btn-sm" onclick="submitContent('${id}')">🔄 Retry</button>`;
}
return btns || '<span style="font-size:12px;color:var(--muted)">—</span>';
}
function setFilter(status, el) {
currentFilter = status;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
el.classList.add('active');
loadContents();
}
async function submitContent(id) {
await api(`/${id}/submit`, 'POST', { actor: 'user' });
refresh();
}
function openReview(id, action) {
currentContentId = id;
currentAction = action;
document.getElementById('reviewComment').value = '';
document.getElementById('confirmApproveBtn').style.display = action === 'approve' ? '' : 'none';
document.getElementById('confirmRejectBtn').style.display = action === 'reject' ? '' : 'none';
document.getElementById('confirmChangesBtn').style.display = action === 'changes' ? '' : 'none';
document.getElementById('reviewTitle').textContent = {
approve: '✅ Approve Content', reject: '❌ Từ chối Content', changes: '✏️ Yêu cầu sửa đổi'
}[action];
document.getElementById('reviewModal').classList.add('show');
}
async function confirmApprove() {
const comment = document.getElementById('reviewComment').value;
await api(`/${currentContentId}/approve`, 'POST', { actor: 'marketing_team', comment });
closeModal('reviewModal');
refresh();
}
async function confirmReject() {
const comment = document.getElementById('reviewComment').value;
if (!comment.trim()) { alert('Phải nhập lý do từ chối!'); return; }
await api(`/${currentContentId}/reject`, 'POST', { actor: 'marketing_team', comment });
closeModal('reviewModal');
refresh();
}
async function confirmChanges() {
const comment = document.getElementById('reviewComment').value;
if (!comment.trim()) { alert('Phải nhập cần sửa gì!'); return; }
await api(`/${currentContentId}/changes`, 'POST', { actor: 'marketing_team', comment });
closeModal('reviewModal');
refresh();
}
async function publishNow(id) {
if (!confirm('Publish ngay lên tất cả platforms?')) return;
const r = await api(`/${id}/publish-now`, 'POST');
alert(r.ok ? '🚀 Đã publish thành công!' : '❌ Lỗi: ' + (r.detail || r.error));
refresh();
}
function openCreate() { document.getElementById('createModal').classList.add('show'); }
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
async function createContent() {
const title = document.getElementById('newTitle').value.trim();
const caption = document.getElementById('newCaption').value.trim();
if (!title) { alert('Nhập tiêu đề!'); return; }
const platforms = [];
if (document.getElementById('fb').checked) platforms.push('facebook');
if (document.getElementById('ig').checked) platforms.push('instagram');
if (document.getElementById('tt').checked) platforms.push('tiktok');
await api('', 'POST', {
title, caption, platforms,
created_by: document.getElementById('newCreatedBy').value || 'ai_agent',
auto_submit: document.getElementById('autoSubmit').checked,
});
closeModal('createModal');
document.getElementById('newTitle').value = '';
document.getElementById('newCaption').value = '';
refresh();
}
// Init
refresh();
setInterval(refresh, 30000);
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Canifa — Content Calendar</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
--bg: #0d0d14; --surface: #13131f; --card: #1a1a2e;
--border: #2a2a40; --primary: #6366f1; --primary-light: #818cf8;
--green: #10b981; --yellow: #f59e0b; --red: #ef4444; --blue: #3b82f6;
--text: #e2e8f0; --muted: #64748b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); }
header {
background: linear-gradient(135deg, #1e1b4b 0%, #13131f 100%);
border-bottom: 1px solid var(--border);
padding: 16px 28px; display: flex; align-items: center; justify-content: space-between;
}
.logo { display: flex; align-items: center; gap: 12px; }
.logo-icon { width: 36px; height: 36px; background: linear-gradient(135deg, #3b82f6, #6366f1); border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 18px; }
h1 { font-size: 18px; font-weight: 700; }
.btn { padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; border: none; transition: all .2s; font-family: inherit; }
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-light); }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--muted); }
.btn-ghost:hover { border-color: var(--primary); color: var(--text); }
main { padding: 24px 28px; max-width: 1400px; margin: 0 auto; }
/* Layout */
.layout { display: grid; grid-template-columns: 1fr 340px; gap: 24px; }
/* Calendar */
.calendar-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.week-nav { display: flex; align-items: center; gap: 12px; }
.week-label { font-size: 16px; font-weight: 600; }
.nav-btn { background: var(--card); border: 1px solid var(--border); color: var(--text); border-radius: 8px; padding: 6px 12px; cursor: pointer; font-size: 18px; }
.nav-btn:hover { border-color: var(--primary); }
.calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 8px; }
.day-col {
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
padding: 12px; min-height: 160px;
}
.day-col.today { border-color: var(--primary); }
.day-header { font-size: 11px; color: var(--muted); margin-bottom: 4px; text-transform: uppercase; letter-spacing: .5px; }
.day-date { font-size: 20px; font-weight: 700; margin-bottom: 10px; }
.day-col.today .day-date { color: var(--primary); }
.cal-entry {
background: rgba(99,102,241,.12); border: 1px solid rgba(99,102,241,.2);
border-radius: 6px; padding: 6px 8px; margin-bottom: 6px;
font-size: 11px; line-height: 1.3; cursor: pointer;
transition: background .15s;
}
.cal-entry:hover { background: rgba(99,102,241,.25); }
.cal-entry-time { color: var(--primary-light); font-weight: 600; margin-bottom: 2px; }
.cal-entry-title { color: var(--text); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cal-entry-platforms { color: var(--muted); font-size: 10px; }
/* Queue sidebar */
.queue-panel { background: var(--card); border: 1px solid var(--border); border-radius: 14px; padding: 0; overflow: hidden; height: fit-content; }
.queue-header { padding: 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.queue-title { font-weight: 600; font-size: 14px; }
.queue-list { padding: 12px; max-height: 600px; overflow-y: auto; }
.queue-entry {
display: flex; align-items: flex-start; gap: 10px;
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 10px; margin-bottom: 8px; cursor: grab;
transition: box-shadow .15s;
}
.queue-entry:hover { box-shadow: 0 4px 12px rgba(0,0,0,.3); }
.queue-num { font-size: 11px; color: var(--muted); min-width: 18px; text-align: center; padding-top: 2px; }
.queue-info { flex: 1; min-width: 0; }
.queue-title-text { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.queue-time { font-size: 11px; color: var(--blue); margin-top: 2px; }
.queue-remove { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 14px; padding: 2px; }
.queue-remove:hover { color: var(--red); }
/* Slots panel */
.slots-panel { margin-top: 20px; }
.slots-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.slot-item { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 8px; font-size: 12px; }
.slot-day { color: var(--muted); font-size: 11px; }
.slot-time { font-weight: 600; color: var(--primary-light); }
.empty-cal { text-align: center; color: var(--muted); font-size: 12px; padding: 20px 0; }
</style>
</head>
<body>
<header>
<div class="logo">
<div class="logo-icon">📅</div>
<div>
<h1>Content Calendar</h1>
<div style="font-size:12px;color:var(--muted)">Lịch đăng Social Media - Canifa</div>
</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-ghost" onclick="refresh()">🔄 Refresh</button>
<button class="btn btn-primary" onclick="triggerPublish()">⚡ Trigger Publish</button>
</div>
</header>
<main>
<div class="layout">
<!-- Calendar -->
<div>
<div class="calendar-header">
<div class="week-nav">
<button class="nav-btn" onclick="changeWeek(-1)">‹</button>
<div class="week-label" id="weekLabel">—</div>
<button class="nav-btn" onclick="changeWeek(1)">›</button>
<button class="btn btn-ghost" onclick="changeWeek(0, true)" style="padding:6px 10px;font-size:12px">Hôm nay</button>
</div>
</div>
<div class="calendar-grid" id="calendarGrid">
<div class="empty-cal">⏳ Đang tải...</div>
</div>
</div>
<!-- Queue sidebar -->
<div>
<div class="queue-panel">
<div class="queue-header">
<div class="queue-title">📋 Hàng chờ đăng</div>
<span id="queueCount" style="font-size:12px;color:var(--muted)">0 bài</span>
</div>
<div class="queue-list" id="queueList">
<div class="empty-cal">Chưa có bài nào trong queue</div>
</div>
</div>
<!-- Posting Slots -->
<div class="slots-panel">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<div style="font-weight:600;font-size:14px">⏰ Posting Slots</div>
<button class="btn btn-ghost" style="padding:5px 10px;font-size:11px" onclick="resetSlots()">Reset Default</button>
</div>
<div class="slots-grid" id="slotsGrid">
<div class="empty-cal">Đang tải slots...</div>
</div>
</div>
</div>
</div>
</main>
<script>
const DAY_NAMES = ['Thứ 2', 'Thứ 3', 'Thứ 4', 'Thứ 5', 'Thứ 6', 'Thứ 7', 'CN'];
let weekOffset = 0;
async function api(path, method = 'GET', body = null) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
const r = await fetch('/api/queue' + path, opts);
return r.json();
}
async function loadCalendar() {
const data = await api(`/calendar?week_offset=${weekOffset}`);
const start = new Date(data.week_start + 'T00:00:00Z');
const end = new Date(start); end.setDate(start.getDate() + 6);
document.getElementById('weekLabel').textContent =
`${start.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit' })} – ${end.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
const grid = document.getElementById('calendarGrid');
grid.innerHTML = (data.days || []).map(day => {
const entries = (day.entries || []).map(e => {
const scheduled = e.scheduled_at ? new Date(e.scheduled_at).toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' }) : '';
const title = e.content?.title || e.content_id;
const platforms = (e.content?.platforms || []).join(', ');
return `<div class="cal-entry">
<div class="cal-entry-time">${scheduled}</div>
<div class="cal-entry-title">${title}</div>
<div class="cal-entry-platforms">${platforms}</div>
</div>`;
}).join('');
return `<div class="day-col ${day.is_today ? 'today' : ''}">
<div class="day-header">${day.day_name}</div>
<div class="day-date">${day.date.split('-')[2]}</div>
${entries || '<div class="empty-cal">—</div>'}
</div>`;
}).join('');
}
async function loadQueue() {
const data = await api('');
const queue = data.queue || [];
document.getElementById('queueCount').textContent = `${queue.length} bài`;
const list = document.getElementById('queueList');
if (!queue.length) {
list.innerHTML = '<div class="empty-cal">Chưa có bài nào trong queue</div>';
return;
}
list.innerHTML = queue.map((e, i) => {
const title = e.content?.title || e.content_id;
const time = e.scheduled_at ? new Date(e.scheduled_at).toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '(chưa assign)';
return `<div class="queue-entry">
<div class="queue-num">${i + 1}</div>
<div class="queue-info">
<div class="queue-title-text">${title}</div>
<div class="queue-time">${time}</div>
</div>
<button class="queue-remove" onclick="removeFromQueue('${e.content_id}')" title="Xóa khỏi queue">×</button>
</div>`;
}).join('');
}
async function loadSlots() {
const data = await api('/slots');
const slots = data.slots || [];
const dayMap = ['T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'CN'];
document.getElementById('slotsGrid').innerHTML = slots.map(s =>
`<div class="slot-item">
<div class="slot-day">${s.label || dayMap[s.day] || 'Ngày ' + s.day}</div>
<div class="slot-time">${String(s.hour).padStart(2,'0')}:${String(s.minute).padStart(2,'0')}</div>
</div>`
).join('') || '<div class="empty-cal">Không có slots</div>';
}
async function removeFromQueue(id) {
await api(`/${id}`, 'DELETE');
refresh();
}
async function triggerPublish() {
const r = await api('/publish-tick', 'POST');
alert(`⚡ Published ${r.published_count || 0} bài!`);
refresh();
}
async function resetSlots() {
if (!confirm('Reset về default slots?')) return;
await api('/slots/reset', 'POST');
loadSlots();
loadCalendar();
}
function changeWeek(delta, reset = false) {
if (reset) weekOffset = 0;
else weekOffset += delta;
loadCalendar();
}
function refresh() {
loadCalendar();
loadQueue();
loadSlots();
}
refresh();
setInterval(refresh, 60000);
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Canifa — Content Composer</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
--bg: #0d0d14; --surface: #13131f; --card: #1a1a2e; --border: #2a2a40;
--primary: #6366f1; --primary-light: #818cf8;
--green: #10b981; --yellow: #f59e0b; --red: #ef4444;
--text: #e2e8f0; --muted: #64748b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
header { background: linear-gradient(135deg, #1e1b4b, #13131f); border-bottom: 1px solid var(--border); padding: 14px 24px; display: flex; align-items: center; justify-content: space-between; }
.logo { display: flex; align-items: center; gap: 10px; }
.logo-icon { width: 34px; height: 34px; background: linear-gradient(135deg, #f59e0b, #ef4444); border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 18px; }
h1 { font-size: 17px; font-weight: 700; }
.btn { padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; border: none; font-family: inherit; transition: all .2s; }
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-light); }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--muted); }
.btn-ghost:hover { border-color: var(--primary); color: var(--text); }
.btn-green { background: var(--green); color: white; }
.btn-yellow { background: var(--yellow); color: #000; }
main { display: grid; grid-template-columns: 1fr 360px; gap: 0; max-width: 1400px; margin: 0 auto; padding: 24px; gap: 24px; }
/* Composer */
.composer { background: var(--card); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; }
.comp-header { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.comp-title { font-size: 14px; font-weight: 600; }
/* Platform selector */
.platform-row { padding: 14px 20px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; flex-wrap: wrap; }
.platform-toggle { display: flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 8px; border: 1px solid var(--border); cursor: pointer; font-size: 12px; transition: all .15s; }
.platform-toggle.active { background: rgba(99,102,241,.15); border-color: var(--primary); }
.platform-toggle input { display: none; }
/* Caption editor */
.caption-area { padding: 16px 20px; }
#captionInput { width: 100%; background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: 10px; padding: 14px; font-family: inherit; font-size: 14px; line-height: 1.6; resize: none; min-height: 180px; }
#captionInput:focus { outline: none; border-color: var(--primary); }
.char-counter { font-size: 11px; color: var(--muted); text-align: right; margin-top: 6px; }
.char-counter.warn { color: var(--yellow); }
.char-counter.over { color: var(--red); }
/* AI bar */
.ai-bar { padding: 12px 20px; border-top: 1px solid var(--border); background: rgba(99,102,241,.05); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.ai-bar-label { font-size: 12px; color: var(--muted); }
.ai-btn { padding: 5px 12px; border-radius: 6px; font-size: 11px; cursor: pointer; border: 1px solid var(--border); background: var(--card); color: var(--text); font-family: inherit; transition: all .15s; }
.ai-btn:hover { border-color: var(--primary); }
/* Submit bar */
.submit-bar { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; gap: 8px; align-items: center; }
.submit-actions { display: flex; gap: 6px; margin-left: auto; }
/* Sidebar */
.sidebar { display: flex; flex-direction: column; gap: 20px; }
/* Templates */
.templates-panel { background: var(--card); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; }
.panel-header { padding: 14px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.panel-title { font-size: 13px; font-weight: 600; }
.cat-scroll { display: flex; gap: 6px; padding: 10px 14px; overflow-x: auto; border-bottom: 1px solid var(--border); }
.cat-pill { padding: 4px 10px; border-radius: 16px; font-size: 11px; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--muted); font-family: inherit; white-space: nowrap; transition: all .15s; }
.cat-pill.active { background: var(--primary); border-color: var(--primary); color: white; }
.templates-list { height: 320px; overflow-y: auto; padding: 10px; }
.template-card { background: var(--surface); border: 2px solid transparent; border-radius: 10px; padding: 10px 12px; margin-bottom: 8px; cursor: pointer; transition: all .15s; position: relative; }
.template-card:hover { border-color: var(--border); }
.template-card.selected { border-color: var(--primary); }
.t-emoji { font-size: 18px; margin-bottom: 4px; }
.t-title { font-size: 12px; font-weight: 600; margin-bottom: 2px; }
.t-desc { font-size: 11px; color: var(--muted); line-height: 1.4; }
.t-use { position: absolute; top: 8px; right: 8px; font-size: 10px; padding: 3px 8px; border-radius: 5px; background: var(--primary); color: white; opacity: 0; transition: opacity .15s; cursor: pointer; border: none; font-family: inherit; }
.template-card:hover .t-use { opacity: 1; }
/* Schedule */
.schedule-panel { background: var(--card); border: 1px solid var(--border); border-radius: 14px; padding: 16px; }
.schedule-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.schedule-label { font-size: 12px; color: var(--muted); min-width: 80px; }
.sched-input { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 6px 10px; font-family: inherit; font-size: 12px; flex: 1; }
.sched-input:focus { outline: none; border-color: var(--primary); }
/* Preview */
.preview-panel { background: var(--card); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; }
.preview-body { padding: 14px; }
.phone-mockup { background: var(--surface); border-radius: 10px; padding: 12px; font-size: 12px; line-height: 1.6; min-height: 100px; color: var(--text); white-space: pre-wrap; border: 1px solid var(--border); }
.loading-ai { color: var(--muted); font-size: 12px; text-align: center; padding: 12px; }
</style>
</head>
<body>
<header>
<div class="logo">
<div class="logo-icon">✍️</div>
<h1>Content Composer</h1>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-ghost" onclick="clearAll()">🗑️ Clear</button>
</div>
</header>
<main>
<!-- Composer -->
<div>
<div class="composer">
<div class="comp-header">
<div class="comp-title">📝 Soạn nội dung</div>
<div style="font-size:12px;color:var(--muted)">Hỗ trợ đa nền tảng</div>
</div>
<!-- Platforms -->
<div class="platform-row">
<label class="platform-toggle active" id="ptFb"><input type="checkbox" checked onchange="updatePlatforms()"/>📘 Facebook</label>
<label class="platform-toggle" id="ptIg"><input type="checkbox" onchange="updatePlatforms()"/>📸 Instagram</label>
<label class="platform-toggle" id="ptTk"><input type="checkbox" onchange="updatePlatforms()"/>🎵 TikTok</label>
<label class="platform-toggle" id="ptYt"><input type="checkbox" onchange="updatePlatforms()"/>▶️ YouTube</label>
</div>
<!-- Caption -->
<div class="caption-area">
<textarea id="captionInput" placeholder="Nhập nội dung bài đăng... hoặc chọn template bên phải để bắt đầu 👉" oninput="onCaptionInput()"></textarea>
<div class="char-counter" id="charCounter">0 / 2200</div>
</div>
<!-- AI tools -->
<div class="ai-bar">
<span class="ai-bar-label">✨ AI:</span>
<button class="ai-btn" onclick="aiGenerate('improve')">🪄 Cải thiện</button>
<button class="ai-btn" onclick="aiGenerate('shorter')">✂️ Rút gọn</button>
<button class="ai-btn" onclick="aiGenerate('hashtags')">🏷️ Thêm hashtag</button>
<button class="ai-btn" onclick="aiGenerate('emoji')">😊 Thêm emoji</button>
<button class="ai-btn" onclick="aiGenerate('cta')">👆 Thêm CTA</button>
</div>
<!-- Submit -->
<div class="submit-bar">
<div style="font-size:12px;color:var(--muted)">
Platform: <strong id="platformDisplay">Facebook</strong>
</div>
<div class="submit-actions">
<button class="btn btn-ghost" onclick="saveDraft()">💾 Lưu nháp</button>
<button class="btn btn-yellow" onclick="submitForApproval()">📋 Gửi duyệt</button>
<button class="btn btn-green" onclick="schedulePost()">📅 Lên lịch</button>
</div>
</div>
</div>
<!-- Preview -->
<div class="preview-panel" style="margin-top:20px">
<div class="panel-header">
<div class="panel-title">👁️ Preview</div>
</div>
<div class="preview-body">
<div class="phone-mockup" id="previewBox">Preview sẽ hiển thị tại đây khi bạn nhập nội dung...</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<!-- Templates -->
<div class="templates-panel">
<div class="panel-header">
<div class="panel-title">📦 Templates</div>
<input type="text" id="tplSearch" placeholder="Tìm..." style="background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:11px;font-family:inherit;width:100px" oninput="searchTemplates()"/>
</div>
<div class="cat-scroll" id="catScroll">
<button class="cat-pill active" onclick="setCategory('all',this)">Tất cả</button>
</div>
<div class="templates-list" id="templatesList">
<div class="loading-ai">⏳ Đang tải templates...</div>
</div>
</div>
<!-- Schedule -->
<div class="schedule-panel">
<div style="font-size:13px;font-weight:600;margin-bottom:12px">📅 Lên lịch đăng</div>
<div class="schedule-row">
<span class="schedule-label">Ngày giờ</span>
<input type="datetime-local" class="sched-input" id="schedDatetime"/>
</div>
<div class="schedule-row">
<span class="schedule-label">Auto-slot</span>
<button class="btn btn-ghost" style="font-size:11px;padding:5px 10px" onclick="autoSlot()">⚡ Dùng slot tối ưu</button>
</div>
</div>
</div>
</main>
<script>
let allTemplates = [], categories = [], activeCat = 'all';
let selectedPlatforms = ['facebook'];
const CHAR_LIMITS = { facebook: 63206, instagram: 2200, tiktok: 2200, youtube: 5000 };
// ─── Templates ────────────────────────────────────────────────────────────
async function loadTemplates() {
try {
const [tplRes, catRes] = await Promise.all([
fetch('/api/templates?limit=100'),
fetch('/api/templates/categories'),
]);
const tplData = await tplRes.json();
const catData = await catRes.json();
allTemplates = tplData.templates || [];
categories = catData.categories || [];
renderCategories();
renderTemplates(allTemplates);
} catch(e) {
document.getElementById('templatesList').innerHTML = '<div class="loading-ai">⚠️ Không thể tải templates</div>';
}
}
function renderCategories() {
const scroll = document.getElementById('catScroll');
scroll.innerHTML = `<button class="cat-pill active" onclick="setCategory('all',this)">Tất cả</button>`;
categories.filter(c => c.slug !== 'all').forEach(c => {
const b = document.createElement('button');
b.className = 'cat-pill';
b.textContent = c.label;
b.onclick = () => setCategory(c.slug, b);
scroll.appendChild(b);
});
}
function setCategory(cat, el) {
activeCat = cat;
document.querySelectorAll('.cat-pill').forEach(p => p.classList.remove('active'));
el.classList.add('active');
const q = document.getElementById('tplSearch').value.toLowerCase();
filterAndRender(q);
}
function searchTemplates() {
const q = document.getElementById('tplSearch').value.toLowerCase();
filterAndRender(q);
}
function filterAndRender(q) {
let filtered = allTemplates;
if (activeCat !== 'all') filtered = filtered.filter(t => t.category === activeCat);
if (q) filtered = filtered.filter(t => t.title.toLowerCase().includes(q) || (t.description||'').toLowerCase().includes(q));
renderTemplates(filtered);
}
function renderTemplates(list) {
const el = document.getElementById('templatesList');
if (!list.length) { el.innerHTML = '<div class="loading-ai">Không tìm thấy template</div>'; return; }
el.innerHTML = list.map(t => `
<div class="template-card" onclick="useTemplate(${t.id})">
<div class="t-emoji">${t.emoji}</div>
<div class="t-title">${t.title}</div>
<div class="t-desc">${t.description}</div>
<button class="t-use" onclick="event.stopPropagation();useTemplate(${t.id})">Dùng →</button>
</div>`).join('');
}
function useTemplate(id) {
const t = allTemplates.find(x => x.id === id);
if (!t) return;
const caption = document.getElementById('captionInput');
if (caption.value.trim() && !confirm('Ghi đè nội dung hiện tại?')) return;
caption.value = t.body || `${t.emoji} ${t.title}\n\n${t.description}`;
onCaptionInput();
document.querySelectorAll('.template-card').forEach(c => c.classList.remove('selected'));
document.querySelectorAll('.template-card').forEach(c => { if (c.querySelector('button')?.onclick?.toString().includes(id)) c.classList.add('selected'); });
}
// ─── Caption ──────────────────────────────────────────────────────────────
function onCaptionInput() {
const txt = document.getElementById('captionInput').value;
const limit = Math.min(...selectedPlatforms.map(p => CHAR_LIMITS[p] || 2200));
const counter = document.getElementById('charCounter');
counter.textContent = `${txt.length} / ${limit}`;
counter.className = 'char-counter' + (txt.length > limit ? ' over' : txt.length > limit * .8 ? ' warn' : '');
document.getElementById('previewBox').textContent = txt || 'Preview sẽ hiển thị tại đây...';
}
function updatePlatforms() {
const map = {ptFb:'facebook', ptIg:'instagram', ptTk:'tiktok', ptYt:'youtube'};
selectedPlatforms = [];
for (const [id, platform] of Object.entries(map)) {
const el = document.getElementById(id);
const checked = el.querySelector('input').checked;
el.classList.toggle('active', checked);
if (checked) selectedPlatforms.push(platform);
}
if (!selectedPlatforms.length) selectedPlatforms = ['facebook'];
document.getElementById('platformDisplay').textContent = selectedPlatforms.map(p => ({facebook:'Facebook',instagram:'Instagram',tiktok:'TikTok',youtube:'YouTube'}[p])).join(', ');
onCaptionInput();
}
// ─── AI ───────────────────────────────────────────────────────────────────
async function aiGenerate(action) {
const caption = document.getElementById('captionInput');
const body = caption.value.trim();
if (!body && action !== 'improve') { alert('Nhập nội dung trước!'); return; }
caption.style.opacity = '0.6';
try {
const r = await fetch('/api/social/ai-enhance', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ action, content: body, platforms: selectedPlatforms }),
});
if (r.ok) {
const d = await r.json();
if (d.content) { caption.value = d.content; onCaptionInput(); }
}
} catch(e) { alert('Không thể kết nối AI. Thử lại sau!'); }
finally { caption.style.opacity = '1'; }
}
// ─── Submit ───────────────────────────────────────────────────────────────
async function saveDraft() {
const body = buildPayload('draft');
const r = await fetch('/api/approval', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
const d = await r.json();
if (d.content_id) { alert('✅ Đã lưu nháp!'); clearAll(); }
}
async function submitForApproval() {
const body = buildPayload('pending_review');
const r = await fetch('/api/approval', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
const d = await r.json();
if (d.content_id) { alert('📋 Đã gửi để duyệt!'); clearAll(); }
}
async function schedulePost() {
const dt = document.getElementById('schedDatetime').value;
if (!dt) { alert('Vui lòng chọn ngày giờ đăng!'); return; }
const body = { ...buildPayload('approved'), scheduled_at: dt };
const r = await fetch('/api/queue/add', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
const d = await r.json();
if (d.success) { alert('📅 Đã lên lịch thành công!'); clearAll(); }
}
async function autoSlot() {
const r = await fetch('/api/queue/next-slot');
const d = await r.json();
if (d.slot) {
document.getElementById('schedDatetime').value = d.slot.replace(' ','T');
}
}
function buildPayload(status) {
return {
title: document.getElementById('captionInput').value.slice(0, 60) + '...',
body: document.getElementById('captionInput').value,
platforms: selectedPlatforms,
status,
created_by: 'composer',
};
}
function clearAll() {
document.getElementById('captionInput').value = '';
onCaptionInput();
}
loadTemplates();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Canifa — Media Library</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
--bg: #0d0d14; --surface: #13131f; --card: #1a1a2e;
--border: #2a2a40; --primary: #6366f1; --primary-light: #818cf8;
--green: #10b981; --yellow: #f59e0b; --red: #ef4444;
--text: #e2e8f0; --muted: #64748b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); }
header {
background: linear-gradient(135deg, #1e1b4b 0%, #13131f 100%);
border-bottom: 1px solid var(--border);
padding: 16px 28px; display: flex; align-items: center; justify-content: space-between;
}
.logo { display: flex; align-items: center; gap: 12px; }
.logo-icon { width: 36px; height: 36px; background: linear-gradient(135deg, #10b981, #3b82f6); border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 18px; }
h1 { font-size: 18px; font-weight: 700; }
.btn { padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; border: none; transition: all .2s; font-family: inherit; }
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-light); }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--muted); }
.btn-ghost:hover { border-color: var(--primary); color: var(--text); }
.btn-sm { padding: 5px 10px; font-size: 11px; }
.btn-red { background: var(--red); color: white; }
main { padding: 24px 28px; max-width: 1600px; margin: 0 auto; }
/* Upload zone */
.upload-zone {
border: 2px dashed var(--border); border-radius: 14px;
padding: 40px; text-align: center; margin-bottom: 24px;
transition: all .2s; cursor: pointer;
}
.upload-zone.dragover { border-color: var(--primary); background: rgba(99,102,241,.05); }
.upload-zone:hover { border-color: var(--primary-light); }
.upload-icon { font-size: 36px; margin-bottom: 10px; }
.upload-text { font-size: 15px; font-weight: 500; margin-bottom: 4px; }
.upload-hint { font-size: 12px; color: var(--muted); }
#fileInput { display: none; }
/* Filters */
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
.search-input {
background: var(--card); border: 1px solid var(--border); color: var(--text);
border-radius: 8px; padding: 8px 14px; font-family: inherit; font-size: 13px;
flex: 1; min-width: 200px;
}
.search-input:focus { outline: none; border-color: var(--primary); }
.filter-btn { padding: 7px 14px; border-radius: 20px; font-size: 12px; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--muted); transition: all .2s; font-family: inherit; }
.filter-btn.active, .filter-btn:hover { background: var(--primary); border-color: var(--primary); color: white; }
/* Asset grid */
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
.asset-card {
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
overflow: hidden; transition: box-shadow .2s, transform .15s;
}
.asset-card:hover { box-shadow: 0 8px 24px rgba(0,0,0,.4); transform: translateY(-2px); }
.asset-thumb {
aspect-ratio: 1; overflow: hidden; background: var(--surface);
display: flex; align-items: center; justify-content: center;
}
.asset-thumb img { width: 100%; height: 100%; object-fit: cover; }
.asset-thumb-placeholder { font-size: 36px; opacity: .4; }
.asset-info { padding: 10px 12px; }
.asset-name { font-size: 12px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 2px; }
.asset-meta { font-size: 11px; color: var(--muted); }
.asset-actions { display: flex; gap: 4px; margin-top: 8px; flex-wrap: wrap; }
/* Detail modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.8); display: none; align-items: center; justify-content: center; z-index: 100; }
.modal-overlay.show { display: flex; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 24px; max-width: 600px; width: 90vw; }
.modal h3 { font-size: 16px; font-weight: 700; margin-bottom: 16px; }
.modal img { width: 100%; border-radius: 8px; margin-bottom: 16px; max-height: 300px; object-fit: contain; background: var(--bg); }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 16px; }
.meta-item { background: var(--card); border-radius: 8px; padding: 8px 12px; }
.meta-key { font-size: 11px; color: var(--muted); margin-bottom: 2px; }
.meta-val { font-size: 13px; font-weight: 500; }
.platform-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; }
.platform-btn { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 8px; text-align: center; cursor: pointer; font-size: 11px; font-family: inherit; color: var(--text); transition: all .15s; }
.platform-btn:hover { border-color: var(--primary); }
.modal-footer { display: flex; gap: 8px; justify-content: flex-end; }
.stats-row { display: flex; gap: 16px; margin-bottom: 24px; }
.stat-card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 14px 20px; display: flex; align-items: center; gap: 12px; }
.stat-icon { font-size: 22px; }
.stat-number { font-size: 20px; font-weight: 700; }
.stat-label { font-size: 11px; color: var(--muted); }
.uploading { position: fixed; bottom: 24px; right: 24px; background: var(--primary); color: white; padding: 12px 20px; border-radius: 12px; font-size: 13px; font-weight: 500; z-index: 200; display: none; }
</style>
</head>
<body>
<header>
<div class="logo">
<div class="logo-icon">🖼️</div>
<div>
<h1>Media Library</h1>
<div style="font-size:12px;color:var(--muted)">Thư viện ảnh & video sản phẩm Canifa</div>
</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-ghost" onclick="refresh()">🔄 Refresh</button>
<button class="btn btn-primary" onclick="document.getElementById('fileInput').click()">📁 Upload</button>
</div>
</header>
<input type="file" id="fileInput" multiple accept="image/*,video/*" onchange="handleFileSelect(event)"/>
<main>
<!-- Stats -->
<div class="stats-row">
<div class="stat-card"><div class="stat-icon">🖼️</div><div><div class="stat-number" id="statImages">—</div><div class="stat-label">Ảnh</div></div></div>
<div class="stat-card"><div class="stat-icon">🎬</div><div><div class="stat-number" id="statVideos">—</div><div class="stat-label">Video</div></div></div>
<div class="stat-card"><div class="stat-icon">📦</div><div><div class="stat-number" id="statTotal">—</div><div class="stat-label">Tổng</div></div></div>
</div>
<!-- Upload zone -->
<div class="upload-zone" id="uploadZone" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">☁️</div>
<div class="upload-text">Kéo thả ảnh/video vào đây hoặc click để chọn</div>
<div class="upload-hint">Hỗ trợ: JPG, PNG, WebP, GIF, MP4, MOV · Tối đa 50MB</div>
</div>
<!-- Toolbar -->
<div class="toolbar">
<input type="text" class="search-input" id="searchInput" placeholder="🔍 Tìm kiếm asset..." oninput="filterAssets()"/>
<button class="filter-btn active" onclick="setFilter('', this)">Tất cả</button>
<button class="filter-btn" onclick="setFilter('image', this)">🖼️ Ảnh</button>
<button class="filter-btn" onclick="setFilter('video', this)">🎬 Video</button>
</div>
<!-- Grid -->
<div class="asset-grid" id="assetGrid">
<div style="color:var(--muted);text-align:center;padding:60px;grid-column:1/-1">⏳ Đang tải...</div>
</div>
</main>
<!-- Detail Modal -->
<div class="modal-overlay" id="detailModal">
<div class="modal">
<h3 id="modalTitle">Chi tiết Asset</h3>
<img id="modalImg" alt="" style="display:none"/>
<div id="modalVideo" style="display:none;margin-bottom:16px"></div>
<div class="meta-grid" id="modalMeta"></div>
<div style="font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:600">Resize theo Platform:</div>
<div class="platform-grid" id="platformGrid"></div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal()">Đóng</button>
<button class="btn btn-red" id="deleteBtn">🗑️ Xóa</button>
</div>
</div>
</div>
<div class="uploading" id="uploadingToast">⏳ Đang upload...</div>
<script>
let allAssets = [];
let currentFilter = '';
let currentAssetId = '';
let platforms = [];
const uploadZone = document.getElementById('uploadZone');
uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('dragover'); });
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
uploadZone.addEventListener('drop', e => {
e.preventDefault(); uploadZone.classList.remove('dragover');
uploadFiles(e.dataTransfer.files);
});
async function api(path, method = 'GET', body = null) {
const opts = { method };
if (body) { opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify(body); }
const r = await fetch('/api/media' + path, opts);
return r.json();
}
async function uploadFiles(files) {
document.getElementById('uploadingToast').style.display = 'block';
for (const file of files) {
const fd = new FormData();
fd.append('file', file);
try {
await fetch(`/api/media/upload?uploaded_by=user`, { method: 'POST', body: fd });
} catch(e) { console.error(e); }
}
document.getElementById('uploadingToast').style.display = 'none';
refresh();
}
function handleFileSelect(e) { uploadFiles(e.target.files); }
async function refresh() {
const data = await api('');
allAssets = data.assets || [];
const imgs = allAssets.filter(a => a.content_type?.startsWith('image/')).length;
const vids = allAssets.filter(a => a.content_type?.startsWith('video/')).length;
document.getElementById('statImages').textContent = imgs;
document.getElementById('statVideos').textContent = vids;
document.getElementById('statTotal').textContent = allAssets.length;
const pData = await api('/platforms');
platforms = pData.platforms || [];
renderAssets();
}
function setFilter(f, el) {
currentFilter = f;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
el.classList.add('active');
renderAssets();
}
function filterAssets() { renderAssets(); }
function renderAssets() {
const q = document.getElementById('searchInput').value.toLowerCase();
let filtered = allAssets;
if (currentFilter) filtered = filtered.filter(a => a.content_type?.startsWith(currentFilter));
if (q) filtered = filtered.filter(a => (a.filename || '').toLowerCase().includes(q));
const grid = document.getElementById('assetGrid');
if (!filtered.length) {
grid.innerHTML = '<div style="color:var(--muted);text-align:center;padding:60px;grid-column:1/-1">📭 Không tìm thấy asset</div>';
return;
}
grid.innerHTML = filtered.map(a => {
const isImg = a.content_type?.startsWith('image/');
const isVid = a.content_type?.startsWith('video/');
const thumb = a.thumb_url
? `<img src="${a.thumb_url}" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">`
: `<div class="asset-thumb-placeholder">${isVid ? '🎬' : '📄'}</div>`;
const size = a.size_bytes ? (a.size_bytes / 1024 / 1024).toFixed(1) + ' MB' : '';
const ts = a.created_at ? new Date(a.created_at).toLocaleDateString('vi-VN') : '';
return `
<div class="asset-card">
<div class="asset-thumb" onclick="openDetail('${a.id}')" style="cursor:pointer">${thumb}</div>
<div class="asset-info">
<div class="asset-name" title="${a.filename}">${a.filename || a.id}</div>
<div class="asset-meta">${size} · ${ts}</div>
<div class="asset-actions">
<button class="btn btn-ghost btn-sm" onclick="openDetail('${a.id}')">🔍 Chi tiết</button>
<button class="btn btn-red btn-sm" onclick="deleteAsset('${a.id}')">🗑️</button>
</div>
</div>
</div>`;
}).join('');
}
async function openDetail(id) {
currentAssetId = id;
const a = allAssets.find(x => x.id === id);
if (!a) return;
document.getElementById('modalTitle').textContent = a.filename || id;
const img = document.getElementById('modalImg');
const vid = document.getElementById('modalVideo');
if (a.content_type?.startsWith('image/')) {
img.src = a.url; img.style.display = ''; vid.style.display = 'none';
} else if (a.content_type?.startsWith('video/')) {
img.style.display = 'none';
vid.innerHTML = `<video controls style="width:100%;border-radius:8px;background:#000;max-height:250px"><source src="${a.url}" type="${a.content_type}"></video>`;
vid.style.display = '';
}
const meta = a.metadata || {};
document.getElementById('modalMeta').innerHTML = [
['Kích thước file', a.size_bytes ? (a.size_bytes / 1024).toFixed(0) + ' KB' : '—'],
['Định dạng', a.content_type || '—'],
['Chiều rộng', meta.width ? meta.width + 'px' : '—'],
['Chiều cao', meta.height ? meta.height + 'px' : '—'],
['Thời lượng', meta.duration ? meta.duration.toFixed(1) + 's' : '—'],
['Upload bởi', a.uploaded_by || '—'],
].map(([k, v]) => `<div class="meta-item"><div class="meta-key">${k}</div><div class="meta-val">${v}</div></div>`).join('');
// Platform resize buttons
document.getElementById('platformGrid').innerHTML = platforms.map(p =>
`<button class="platform-btn" onclick="resizeAsset('${id}', '${p.key}')">
<div style="font-weight:600">${p.key.replace('_', ' ')}</div>
<div style="color:var(--muted)">${p.width}×${p.height}</div>
</button>`
).join('');
document.getElementById('deleteBtn').onclick = () => deleteAsset(id);
document.getElementById('detailModal').classList.add('show');
}
function closeModal() { document.getElementById('detailModal').classList.remove('show'); }
async function deleteAsset(id) {
if (!confirm('Xóa asset này?')) return;
await fetch(`/api/media/${id}`, { method: 'DELETE' });
closeModal();
refresh();
}
async function resizeAsset(id, platform) {
window.open(`/api/media/${id}/resize?platform=${platform}`, '_blank');
}
refresh();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Canifa — Social Inbox</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
--bg: #0d0d14; --surface: #13131f; --card: #1a1a2e; --border: #2a2a40;
--primary: #6366f1; --primary-light: #818cf8;
--green: #10b981; --yellow: #f59e0b; --red: #ef4444; --blue: #3b82f6;
--text: #e2e8f0; --muted: #64748b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
header { background: linear-gradient(135deg, #1e1b4b, #13131f); border-bottom: 1px solid var(--border); padding: 12px 20px; display: flex; align-items: center; gap: 16px; flex-shrink: 0; }
.logo { display: flex; align-items: center; gap: 10px; }
.logo-icon { width: 32px; height: 32px; background: linear-gradient(135deg, #3b82f6, #6366f1); border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; }
h1 { font-size: 16px; font-weight: 700; }
.stats-bar { display: flex; gap: 12px; margin-left: auto; }
.stat-pill { background: var(--card); border: 1px solid var(--border); border-radius: 20px; padding: 4px 12px; font-size: 12px; display: flex; align-items: center; gap: 6px; }
.stat-dot { width: 8px; height: 8px; border-radius: 50%; }
.btn { padding: 7px 14px; border-radius: 8px; font-size: 12px; font-weight: 500; cursor: pointer; border: none; font-family: inherit; transition: all .2s; }
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-light); }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--muted); }
.btn-ghost:hover { border-color: var(--primary); color: var(--text); }
.layout { display: grid; grid-template-columns: 220px 1fr 370px; flex: 1; overflow: hidden; }
/* Sidebar */
.sidebar { border-right: 1px solid var(--border); padding: 14px; overflow-y: auto; }
.sidebar-title { font-size: 10px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .8px; margin-bottom: 8px; }
.sidebar-section { margin-bottom: 18px; }
.nav-item { display: flex; align-items: center; gap: 10px; padding: 7px 10px; border-radius: 8px; cursor: pointer; font-size: 13px; transition: background .15s; }
.nav-item:hover { background: var(--card); }
.nav-item.active { background: rgba(99,102,241,.15); color: var(--primary-light); }
.nav-count { margin-left: auto; background: var(--primary); color: white; font-size: 10px; font-weight: 700; border-radius: 10px; padding: 1px 6px; }
.filter-chip { display: inline-flex; align-items: center; gap: 4px; background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; font-size: 11px; cursor: pointer; margin: 2px; transition: all .15s; }
.filter-chip.active { background: var(--primary); border-color: var(--primary); color: white; }
/* Message list */
.msg-list { border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
.msg-list-header { padding: 10px 14px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.search-input { background: var(--card); border: 1px solid var(--border); color: var(--text); border-radius: 8px; padding: 7px 12px; font-family: inherit; font-size: 12px; flex: 1; }
.search-input:focus { outline: none; border-color: var(--primary); }
.msg-items { overflow-y: auto; flex: 1; }
.msg-item { padding: 11px 14px; border-bottom: 1px solid var(--border); cursor: pointer; transition: background .1s; display: flex; gap: 10px; }
.msg-item:hover { background: var(--card); }
.msg-item.active { background: rgba(99,102,241,.1); border-left: 3px solid var(--primary); }
.msg-item.unread { background: rgba(99,102,241,.05); }
.msg-avatar { width: 34px; height: 34px; border-radius: 50%; background: linear-gradient(135deg, #6366f1, #3b82f6); display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 600; flex-shrink: 0; color: white; }
.msg-body { flex: 1; min-width: 0; }
.msg-sender { font-size: 13px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
.msg-preview { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
.msg-meta { display: flex; align-items: center; gap: 4px; margin-top: 4px; flex-wrap: wrap; }
.platform-badge { font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 4px; }
.plt-fb { background: #1877f2; color: white; }
.plt-ig { background: linear-gradient(135deg,#f09433,#e6683c,#dc2743,#cc2366,#bc1888); color: white; }
.plt-tt { background: #000; color: white; }
.plt-yt { background: #ff0000; color: white; }
.type-badge { font-size: 9px; padding: 1px 5px; border-radius: 4px; background: var(--card); border: 1px solid var(--border); color: var(--muted); }
.sent-dot { width: 6px; height: 6px; border-radius: 50%; ml: auto; }
.sent-pos { background: var(--green); }
.sent-neg { background: var(--red); }
.sent-neu { background: var(--muted); }
.msg-time { font-size: 10px; color: var(--muted); margin-left: auto; white-space: nowrap; }
.unread-pip { width: 8px; height: 8px; border-radius: 50%; background: var(--primary); flex-shrink: 0; margin-top: 3px; }
/* Bulk */
.bulk-bar { background: rgba(99,102,241,.15); border-bottom: 1px solid var(--border); padding: 7px 14px; display: none; align-items: center; gap: 8px; font-size: 12px; flex-shrink: 0; }
.bulk-bar.show { display: flex; }
/* Detail */
.detail-panel { display: flex; flex-direction: column; overflow: hidden; }
.detail-empty { flex: 1; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; color: var(--muted); }
.detail-header { padding: 12px 14px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 6px; flex-shrink: 0; flex-wrap: wrap; }
.detail-name { font-size: 14px; font-weight: 600; flex: 1; }
.status-badge { font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 5px; }
.st-unread { background: var(--primary); color: white; }
.st-open { background: rgba(59,130,246,.2); color: var(--blue); }
.st-resolved { background: rgba(16,185,129,.2); color: var(--green); }
.st-archived { background: var(--card); color: var(--muted); }
.assign-select { background: var(--card); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 4px 8px; font-size: 11px; font-family: inherit; }
.detail-body { flex: 1; overflow-y: auto; padding: 14px; }
.bubble { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin-bottom: 10px; }
.bubble-meta { font-size: 11px; color: var(--muted); margin-bottom: 5px; }
.bubble-text { font-size: 13px; line-height: 1.6; }
.reply-bubble { background: rgba(99,102,241,.1); border: 1px solid rgba(99,102,241,.2); border-radius: 12px; padding: 12px 14px; margin-bottom: 8px; }
.note-bubble { background: rgba(245,158,11,.08); border: 1px solid rgba(245,158,11,.2); border-radius: 12px; padding: 10px 14px; margin-bottom: 8px; }
.note-label { font-size: 10px; font-weight: 600; color: var(--yellow); margin-bottom: 3px; }
/* Footer composer */
.detail-footer { border-top: 1px solid var(--border); padding: 10px 14px; flex-shrink: 0; }
.reply-tabs { display: flex; gap: 4px; margin-bottom: 8px; }
.reply-tab { padding: 5px 12px; border-radius: 6px; font-size: 11px; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--muted); font-family: inherit; }
.reply-tab.active { background: var(--primary); border-color: var(--primary); color: white; }
.reply-area { width: 100%; background: var(--card); border: 1px solid var(--border); color: var(--text); border-radius: 8px; padding: 10px 12px; font-family: inherit; font-size: 12px; resize: none; height: 68px; }
.reply-area:focus { outline: none; border-color: var(--primary); }
.reply-actions { display: flex; align-items: center; justify-content: flex-end; margin-top: 6px; gap: 6px; }
.loading { text-align: center; color: var(--muted); padding: 40px; font-size: 13px; }
</style>
</head>
<body>
<header>
<div class="logo">
<div class="logo-icon">📬</div>
<h1>Social Inbox</h1>
</div>
<div class="stats-bar">
<div class="stat-pill"><div class="stat-dot" style="background:var(--red)"></div><span id="statUnread">0</span> Chưa đọc</div>
<div class="stat-pill"><div class="stat-dot" style="background:var(--yellow)"></div><span id="statOpen">0</span> Đang xử lý</div>
<div class="stat-pill"><div class="stat-dot" style="background:var(--green)"></div><span id="statResolved">0</span> Resolved</div>
</div>
<button class="btn btn-ghost" onclick="loadMessages()" style="margin-left:12px">🔄</button>
</header>
<div class="layout">
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-section">
<div class="sidebar-title">Views</div>
<div class="nav-item active" onclick="setView('all',this)">📥 Tất cả <span class="nav-count" id="countAll">0</span></div>
<div class="nav-item" onclick="setView('unassigned',this)">❔ Chưa giao</div>
</div>
<div class="sidebar-section">
<div class="sidebar-title">Platform</div>
<span class="filter-chip" onclick="toggle('platform','facebook',this)">📘 Facebook</span>
<span class="filter-chip" onclick="toggle('platform','instagram',this)">📸 Instagram</span>
<span class="filter-chip" onclick="toggle('platform','tiktok',this)">🎵 TikTok</span>
<span class="filter-chip" onclick="toggle('platform','youtube',this)">▶️ YouTube</span>
</div>
<div class="sidebar-section">
<div class="sidebar-title">Trạng thái</div>
<span class="filter-chip" onclick="toggle('status','unread',this)">🔵 Chưa đọc</span>
<span class="filter-chip" onclick="toggle('status','open',this)">🟡 Đang xử lý</span>
<span class="filter-chip" onclick="toggle('status','resolved',this)">🟢 Resolved</span>
</div>
<div class="sidebar-section">
<div class="sidebar-title">Sentiment</div>
<span class="filter-chip" onclick="toggle('sentiment','positive',this)">😊 Tích cực</span>
<span class="filter-chip" onclick="toggle('sentiment','negative',this)">😠 Tiêu cực</span>
<span class="filter-chip" onclick="toggle('sentiment','neutral',this)">😐 Trung tính</span>
</div>
<div class="sidebar-section">
<div class="sidebar-title">Loại</div>
<span class="filter-chip" onclick="toggle('type','comment',this)">💬 Comment</span>
<span class="filter-chip" onclick="toggle('type','mention',this)">📣 Mention</span>
<span class="filter-chip" onclick="toggle('type','dm',this)">✉️ DM</span>
<span class="filter-chip" onclick="toggle('type','review',this)">⭐ Review</span>
</div>
</div>
<!-- List -->
<div class="msg-list">
<div class="msg-list-header">
<input class="search-input" id="searchInput" placeholder="🔍 Tìm kiếm..." oninput="debounceSearch()"/>
<button class="btn btn-ghost" onclick="selectAll()">Chọn tất</button>
</div>
<div class="bulk-bar" id="bulkBar">
<span id="bulkCount">0 đã chọn</span>
<button class="btn btn-ghost" style="padding:4px 10px;font-size:11px" onclick="bulkAction('mark_read')">✓ Đã đọc</button>
<button class="btn btn-ghost" style="padding:4px 10px;font-size:11px" onclick="bulkAction('resolve')">✅ Resolve</button>
<button class="btn btn-ghost" style="padding:4px 10px;font-size:11px" onclick="bulkAction('archive')">📦 Archive</button>
<button class="btn btn-ghost" style="padding:4px 10px;font-size:11px" onclick="clearSel()">✕</button>
</div>
<div class="msg-items" id="msgItems"><div class="loading">⏳ Đang tải...</div></div>
</div>
<!-- Detail -->
<div class="detail-panel" id="detailPanel">
<div class="detail-empty"><div style="font-size:48px;opacity:.3">💬</div><div>Chọn một tin nhắn để xem</div></div>
</div>
</div>
<script>
const API = '/api/social/inbox';
let messages = [], selectedId = null, selected = new Set(), members = [];
let filters = { platform:[], status:[], sentiment:[], type:[] };
let view = 'all', searchQ = '', srTimer = null;
function toggle(key, val, el) {
el.classList.toggle('active');
const i = filters[key].indexOf(val);
if (i > -1) filters[key].splice(i,1); else filters[key].push(val);
loadMessages();
}
function setView(v, el) {
view = v;
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
el.classList.add('active');
loadMessages();
}
function debounceSearch() {
clearTimeout(srTimer);
srTimer = setTimeout(() => { searchQ = document.getElementById('searchInput').value; loadMessages(); }, 300);
}
async function loadMessages() {
const p = new URLSearchParams({ view });
if (searchQ) p.set('q', searchQ);
filters.platform.forEach(v => p.append('platform', v));
filters.status.forEach(v => p.append('status', v));
filters.sentiment.forEach(v => p.append('sentiment', v));
filters.type.forEach(v => p.append('type', v));
try {
const r = await fetch(`${API}?${p}`);
const d = await r.json();
messages = d.messages || [];
members = d.team_members || [];
renderList();
const s = d.stats || {};
document.getElementById('statUnread').textContent = s.unread || 0;
document.getElementById('statOpen').textContent = s.open || 0;
document.getElementById('statResolved').textContent = s.resolved || 0;
document.getElementById('countAll').textContent = s.total || messages.length;
} catch(e) {
document.getElementById('msgItems').innerHTML = '<div class="loading">⚠️ Không thể kết nối API</div>';
}
}
function renderList() {
const el = document.getElementById('msgItems');
if (!messages.length) { el.innerHTML = '<div class="loading">📭 Không có tin nhắn</div>'; return; }
el.innerHTML = messages.map(m => {
const init = (m.sender_name||'?').slice(0,2).toUpperCase();
const pltMap = {facebook:'plt-fb',instagram:'plt-ig',tiktok:'plt-tt',youtube:'plt-yt'};
const pltLbl = {facebook:'FB',instagram:'IG',tiktok:'TK',youtube:'YT'};
const sentMap = {positive:'sent-pos',negative:'sent-neg',neutral:'sent-neu'};
const t = m.received_at ? new Date(m.received_at).toLocaleString('vi-VN',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '';
return `<div class="msg-item ${m.id===selectedId?'active':''} ${m.status==='unread'?'unread':''}" onclick="openMsg('${m.id}')">
<input type="checkbox" onclick="event.stopPropagation();toggleSel('${m.id}')" ${selected.has(m.id)?'checked':''} style="margin-top:7px;cursor:pointer"/>
<div class="msg-avatar">${init}</div>
<div class="msg-body">
<div class="msg-sender">${m.sender_name||'Unknown'} ${m.status==='unread'?'<div class="unread-pip"></div>':''}</div>
<div class="msg-preview">${m.body||''}</div>
<div class="msg-meta">
<span class="platform-badge ${pltMap[m.platform]||''}">${pltLbl[m.platform]||m.platform}</span>
<span class="type-badge">${m.message_type||'comment'}</span>
<div class="sent-dot ${sentMap[m.sentiment]||'sent-neu'}"></div>
<span class="msg-time">${t}</span>
</div>
</div>
</div>`;
}).join('');
}
async function openMsg(id) {
selectedId = id;
renderList();
try {
const r = await fetch(`${API}/${id}`);
const d = await r.json();
const msg = d.message;
if (!msg) return;
const thread = d.thread || [];
const stMap = {unread:'st-unread',open:'st-open',resolved:'st-resolved',archived:'st-archived'};
const stLbl = {unread:'Chưa đọc',open:'Đang xử lý',resolved:'Resolved',archived:'Archived'};
const pltLbl = {facebook:'📘 Facebook',instagram:'📸 Instagram',tiktok:'🎵 TikTok',youtube:'▶️ YouTube'};
const membOpts = members.map(m => `<option value="${m.id}" ${(msg.assigned_to?.id||msg.assigned_to)===m.id?'selected':''}>${m.name}</option>`).join('');
const threadHtml = thread.map(item => {
if (item.type==='reply') return `<div class="reply-bubble"><div class="bubble-meta">↩️ ${item.author} · ${new Date(item.sent_at).toLocaleString('vi-VN')}</div><div class="bubble-text">${item.body}</div></div>`;
return `<div class="note-bubble"><div class="note-label">📝 Ghi chú nội bộ</div><div class="bubble-meta">${item.author} · ${new Date(item.created_at).toLocaleString('vi-VN')}</div><div class="bubble-text">${item.body}</div></div>`;
}).join('');
document.getElementById('detailPanel').innerHTML = `
<div class="detail-header">
<div class="detail-name">${msg.sender_name}</div>
<span class="status-badge ${stMap[msg.status]||'st-open'}">${stLbl[msg.status]||msg.status}</span>
<select class="assign-select" onchange="assignMsg('${id}',this.value)"><option value="">— Giao cho —</option>${membOpts}</select>
<button class="btn btn-ghost" style="padding:4px 9px;font-size:11px" onclick="chStatus('${id}','resolved')">✅</button>
<button class="btn btn-ghost" style="padding:4px 9px;font-size:11px" onclick="chStatus('${id}','archived')">📦</button>
</div>
<div class="detail-body">
<div class="bubble">
<div class="bubble-meta">${pltLbl[msg.platform]||msg.platform} · ${msg.message_type} · ${new Date(msg.received_at).toLocaleString('vi-VN')} · ${msg.sentiment||'neutral'}</div>
<div class="bubble-text">${msg.body}</div>
</div>
${threadHtml}
</div>
<div class="detail-footer">
<div class="reply-tabs">
<button class="reply-tab active" onclick="switchTab('reply',this)">↩️ Trả lời</button>
<button class="reply-tab" onclick="switchTab('note',this)">📝 Ghi chú</button>
</div>
<textarea class="reply-area" id="replyText" placeholder="Nhập phản hồi..."></textarea>
<div class="reply-actions"><button class="btn btn-primary" onclick="sendReply('${id}')">Gửi →</button></div>
</div>`;
} catch(e) { console.error(e); }
}
function switchTab(tab, el) {
document.querySelectorAll('.reply-tab').forEach(t => t.classList.remove('active'));
el.classList.add('active');
const a = document.getElementById('replyText');
a.placeholder = tab==='reply' ? 'Nhập phản hồi...' : 'Ghi chú nội bộ (chỉ team thấy)...';
a.dataset.tab = tab;
}
async function sendReply(id) {
const a = document.getElementById('replyText');
const body = a.value.trim(); if (!body) return;
const ep = a.dataset.tab==='note' ? `${API}/${id}/note` : `${API}/${id}/reply`;
await fetch(ep, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({body}) });
a.value = '';
openMsg(id);
}
async function assignMsg(id, uid) {
await fetch(`${API}/${id}/assign`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({assigned_to: uid||null}) });
}
async function chStatus(id, status) {
await fetch(`${API}/${id}/status`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({status}) });
loadMessages(); if (selectedId===id) openMsg(id);
}
function toggleSel(id) { selected.has(id) ? selected.delete(id) : selected.add(id); updateBulk(); }
function selectAll() { messages.forEach(m => selected.add(m.id)); renderList(); updateBulk(); }
function clearSel() { selected.clear(); renderList(); updateBulk(); }
function updateBulk() {
document.getElementById('bulkBar').classList.toggle('show', selected.size>0);
document.getElementById('bulkCount').textContent = `${selected.size} đã chọn`;
}
async function bulkAction(action) {
await fetch(`${API}/bulk`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({message_ids:[...selected], action}) });
clearSel(); loadMessages();
}
loadMessages();
setInterval(loadMessages, 30000);
</script>
</body>
</html>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment