From af2e5b4a28e64bc2b2a1f10d815b723570db584a Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 23 Jul 2026 05:56:42 +0000 Subject: [PATCH] fix frontend --- apps/backend/cmd/worker/main.go | 21 +- apps/backend/etc/gateway.example.yaml | 8 +- apps/backend/generate/api/gateway.api | 2 + apps/backend/generate/api/growth.api | 315 +++++++ apps/backend/generate/api/growth_p2.api | 285 ++++++ apps/backend/generate/api/invite.api | 17 + apps/backend/generate/api/m5.api | 14 +- apps/backend/generate/api/studio.api | 24 +- .../000013_growth_loop_indexes.down.json | 12 + .../mongo/000013_growth_loop_indexes.up.json | 42 + apps/backend/internal/config/config.go | 4 +- .../benchmark/get_benchmark_handler.go | 28 + .../checkups/generate_checkup_now_handler.go | 20 + .../handler/checkups/get_checkup_handler.go | 28 + .../checkups/get_latest_checkup_handler.go | 20 + .../handler/checkups/list_checkups_handler.go | 28 + .../health/get_account_health_handler.go | 28 + .../health/list_account_health_handler.go | 20 + .../get_insights_summary_handler.go | 28 + .../outcomes/delete_conversion_handler.go | 28 + .../handler/outcomes/get_outcome_handler.go | 28 + .../outcomes/get_outcome_summary_handler.go | 28 + .../handler/outcomes/list_outcomes_handler.go | 28 + .../outcomes/report_conversion_handler.go | 28 + .../outcomes/update_conversion_handler.go | 28 + .../handler/playbooks/get_playbook_handler.go | 28 + .../playbooks/import_playbook_handler.go | 28 + .../playbooks/list_playbooks_handler.go | 28 + .../playbooks/publish_playbook_handler.go | 28 + .../playbooks/remove_playbook_handler.go | 28 + .../public_pain_keywords_handler.go | 28 + .../publictools/public_style_quiz_handler.go | 28 + .../handler/publicutm/utm_redirect_handler.go | 33 + apps/backend/internal/handler/routes.go | 303 +++++- .../handler/utm/create_utm_link_handler.go | 28 + .../handler/utm/list_utm_links_handler.go | 20 + .../add_workspace_member_handler.go | 28 + .../approve_draft_review_handler.go | 28 + .../workspaces/archive_workspace_handler.go | 28 + .../workspaces/create_workspace_handler.go | 28 + .../export_workspace_report_handler.go | 28 + .../workspaces/list_draft_reviews_handler.go | 28 + .../list_workspace_members_handler.go | 28 + .../workspaces/list_workspaces_handler.go | 20 + .../workspaces/reject_draft_review_handler.go | 28 + .../remove_workspace_member_handler.go | 28 + .../workspaces/submit_draft_review_handler.go | 28 + .../workspaces/switch_workspace_handler.go | 28 + .../update_workspace_branding_handler.go | 28 + .../workspaces/update_workspace_handler.go | 28 + .../logic/benchmark/get_benchmark_logic.go | 66 ++ .../checkups/generate_checkup_now_logic.go | 39 + .../logic/checkups/get_checkup_logic.go | 35 + .../checkups/get_latest_checkup_logic.go | 45 + .../logic/checkups/list_checkups_logic.go | 38 + apps/backend/internal/logic/growthmap/map.go | 166 ++++ .../logic/health/get_account_health_logic.go | 35 + .../logic/health/list_account_health_logic.go | 115 +++ .../insightsapi/get_insights_summary_logic.go | 123 +++ .../invite/get_my_invite_network_logic.go | 30 +- .../logic/outcomes/delete_conversion_logic.go | 33 + .../logic/outcomes/get_outcome_logic.go | 35 + .../outcomes/get_outcome_summary_logic.go | 45 + .../logic/outcomes/list_outcomes_logic.go | 42 + .../logic/outcomes/report_conversion_logic.go | 35 + .../logic/outcomes/update_conversion_logic.go | 35 + .../logic/playbooks/get_playbook_logic.go | 35 + .../logic/playbooks/import_playbook_logic.go | 34 + .../logic/playbooks/list_playbooks_logic.go | 44 + .../logic/playbooks/publish_playbook_logic.go | 39 + .../logic/playbooks/remove_playbook_logic.go | 33 + .../internal/logic/plays/submit_play_logic.go | 16 + .../publictools/public_pain_keywords_logic.go | 29 + .../publictools/public_style_quiz_logic.go | 29 + .../logic/publicutm/utm_redirect_logic.go | 41 + .../logic/scout/send_outreach_logic.go | 8 + .../logic/usage/purchase_plan_logic.go | 31 + .../logic/utm/create_utm_link_logic.go | 38 + .../logic/utm/list_utm_links_logic.go | 42 + .../workspaces/add_workspace_member_logic.go | 56 ++ .../workspaces/approve_draft_review_logic.go | 36 + .../workspaces/archive_workspace_logic.go | 33 + .../workspaces/create_workspace_logic.go | 35 + .../export_workspace_report_logic.go | 38 + .../workspaces/list_draft_reviews_logic.go | 38 + .../list_workspace_members_logic.go | 47 + .../logic/workspaces/list_workspaces_logic.go | 35 + .../workspaces/reject_draft_review_logic.go | 36 + .../remove_workspace_member_logic.go | 33 + .../workspaces/submit_draft_review_logic.go | 35 + .../workspaces/switch_workspace_logic.go | 38 + .../update_workspace_branding_logic.go | 36 + .../workspaces/update_workspace_logic.go | 35 + .../internal/module/growth/domain/domain.go | 300 ++++++ .../module/growth/repository/memory.go | 674 ++++++++++++++ .../module/growth/repository/mongo.go | 473 ++++++++++ .../internal/module/growth/usecase/p2.go | 511 +++++++++++ .../internal/module/growth/usecase/service.go | 861 ++++++++++++++++++ .../module/growth/usecase/service_test.go | 170 ++++ .../module/inspire/usecase/m5_inspire_test.go | 40 + .../module/inspire/usecase/service.go | 57 +- .../module/member/usecase/m1_t119_test.go | 2 +- .../internal/module/scout/domain/domain.go | 9 +- .../internal/module/scout/usecase/service.go | 19 + .../internal/module/studio/domain/domain.go | 9 +- .../internal/module/studio/usecase/service.go | 14 + .../internal/module/usage/domain/usage.go | 7 +- .../internal/module/usage/usecase/service.go | 41 +- .../module/usage/usecase/service_test.go | 10 + apps/backend/internal/response/response.go | 13 + apps/backend/internal/svc/service_context.go | 28 + apps/backend/internal/types/m4_convert.go | 5 +- apps/backend/internal/types/m5_convert.go | 2 + apps/backend/internal/types/types.go | 459 +++++++++- .../haixun-threads-sync/manifest.json | 4 +- .../haixun-threads-sync/options.html | 4 +- apps/extension/haixun-threads-sync/options.js | 2 +- .../haixun-threads-sync/service-worker.js | 4 +- apps/web/.env.example | 2 +- apps/web/src/App.tsx | 14 + apps/web/src/components/layout/MobileDock.tsx | 48 +- apps/web/src/components/layout/SidebarNav.tsx | 41 +- .../components/layout/WorkspaceSwitcher.tsx | 81 ++ .../components/persona/PersonaWorkbench.tsx | 5 + apps/web/src/components/ui/AppIcons.tsx | 27 + apps/web/src/data/live/growthRepo.ts | 179 ++++ apps/web/src/data/live/http.ts | 2 +- apps/web/src/data/live/inviteRepo.ts | 22 + apps/web/src/data/live/m5Repos.ts | 5 + apps/web/src/data/live/repos.ts | 7 + apps/web/src/data/repos.ts | 24 + apps/web/src/domain/types.ts | 91 ++ apps/web/src/lib/i18n/messages.ts | 60 ++ apps/web/src/lib/nav.ts | 75 +- apps/web/src/pages/BenchmarkPage.tsx | 69 ++ apps/web/src/pages/BrandsPage.tsx | 3 + apps/web/src/pages/CrewPage.tsx | 25 +- apps/web/src/pages/InsightsPage.tsx | 61 +- apps/web/src/pages/InvitePage.tsx | 27 + apps/web/src/pages/PlaybooksPage.tsx | 160 ++++ apps/web/src/pages/ScoutPage.tsx | 46 + apps/web/src/pages/TodayPage.tsx | 128 ++- apps/web/src/pages/UsagePage.tsx | 37 +- apps/web/src/pages/UtmLinksPage.tsx | 85 ++ apps/web/src/pages/tools/PainKeywordsPage.tsx | 67 ++ apps/web/src/pages/tools/StyleQuizPage.tsx | 64 ++ apps/web/src/styles/layout.css | 110 ++- apps/web/vite.config.ts | 4 +- deploy/.env.example | 14 +- deploy/README.md | 2 +- deploy/nginx/haixun-dev.conf | 2 +- deploy/prod/README.md | 4 +- deploy/prod/config/gateway.yaml | 8 +- deploy/prod/harbor.env.example | 14 +- .../monitoring/prometheus/targets/tls.yml | 2 +- deploy/prod/nginx/harbor-http.conf | 2 +- deploy/prod/nginx/harbor-tls.conf | 8 +- deploy/prod/remote/bootstrap.sh | 20 +- deploy/prod/remote/enable-tls.sh | 2 +- deploy/scripts/install-nginx.sh | 2 +- docs/product/growth-loop/GAPS.md | 36 + docs/product/growth-loop/README.md | 47 +- docs/product/growth-loop/TESTING.md | 31 + docs/product/growth-loop/plan.md | 190 ++++ docs/product/growth-loop/requirements.md | 119 ++- docs/product/growth-loop/spec.md | 379 ++++++++ docs/product/growth-loop/tasks/INDEX.md | 74 ++ ...lls-outcomes-checkups-health-workspaces.md | 55 ++ .../tasks/T301-mongo-indexes-growth-loop.md | 50 + .../T302-fe-domain-types-repo-interfaces.md | 51 ++ .../T310-outcome-domain-model-repository.md | 47 + .../tasks/T311-outcome-hooks-scout-outbox.md | 51 ++ .../tasks/T312-outcome-observe-job.md | 51 ++ ...313-outcome-api-summary-list-conversion.md | 50 + .../T314-fe-outcomes-today-usage-scout.md | 52 ++ .../T315-test-m1-outcomes-integration.md | 47 + .../tasks/T330-weekly-checkup-model-job.md | 50 + .../tasks/T331-checkup-api-no-usage-debit.md | 50 + .../T332-asset-learning-persona-brand.md | 49 + .../tasks/T333-fe-checkup-learning-ui.md | 48 + .../T334-test-m2-checkup-asset-integration.md | 40 + .../T350-account-health-model-recompute.md | 48 + .../T351-health-gate-auto-send-throttle.md | 44 + .../tasks/T352-fe-health-badge-today-warn.md | 43 + .../tasks/T353-test-m3-health-integration.md | 33 + .../T365-invite-reward-on-first-paid-plan.md | 51 ++ .../tasks/T366-invite-rewards-api-fe.md | 41 + .../T367-test-m4-invite-reward-integration.md | 32 + .../T380-workspace-model-backfill-default.md | 46 + .../T381-workspace-api-resource-filter.md | 49 + .../tasks/T382-draft-review-gate-auto-send.md | 43 + .../T383-workspace-monthly-report-pdf.md | 46 + .../T384-fe-workspace-shell-review-export.md | 43 + .../T385-test-m5-workspace-integration.md | 35 + .../T400-test-matrix-p0-p1-acceptance.md | 43 + .../tasks/T401-smoke-fe-be-growth-loop.md | 44 + .../tasks/T402-gap-doc-p2-and-signal-probe.md | 37 + 197 files changed, 11270 insertions(+), 285 deletions(-) create mode 100644 apps/backend/generate/api/growth.api create mode 100644 apps/backend/generate/api/growth_p2.api create mode 100644 apps/backend/generate/database/mongo/000013_growth_loop_indexes.down.json create mode 100644 apps/backend/generate/database/mongo/000013_growth_loop_indexes.up.json create mode 100644 apps/backend/internal/handler/benchmark/get_benchmark_handler.go create mode 100644 apps/backend/internal/handler/checkups/generate_checkup_now_handler.go create mode 100644 apps/backend/internal/handler/checkups/get_checkup_handler.go create mode 100644 apps/backend/internal/handler/checkups/get_latest_checkup_handler.go create mode 100644 apps/backend/internal/handler/checkups/list_checkups_handler.go create mode 100644 apps/backend/internal/handler/health/get_account_health_handler.go create mode 100644 apps/backend/internal/handler/health/list_account_health_handler.go create mode 100644 apps/backend/internal/handler/insightsapi/get_insights_summary_handler.go create mode 100644 apps/backend/internal/handler/outcomes/delete_conversion_handler.go create mode 100644 apps/backend/internal/handler/outcomes/get_outcome_handler.go create mode 100644 apps/backend/internal/handler/outcomes/get_outcome_summary_handler.go create mode 100644 apps/backend/internal/handler/outcomes/list_outcomes_handler.go create mode 100644 apps/backend/internal/handler/outcomes/report_conversion_handler.go create mode 100644 apps/backend/internal/handler/outcomes/update_conversion_handler.go create mode 100644 apps/backend/internal/handler/playbooks/get_playbook_handler.go create mode 100644 apps/backend/internal/handler/playbooks/import_playbook_handler.go create mode 100644 apps/backend/internal/handler/playbooks/list_playbooks_handler.go create mode 100644 apps/backend/internal/handler/playbooks/publish_playbook_handler.go create mode 100644 apps/backend/internal/handler/playbooks/remove_playbook_handler.go create mode 100644 apps/backend/internal/handler/publictools/public_pain_keywords_handler.go create mode 100644 apps/backend/internal/handler/publictools/public_style_quiz_handler.go create mode 100644 apps/backend/internal/handler/publicutm/utm_redirect_handler.go create mode 100644 apps/backend/internal/handler/utm/create_utm_link_handler.go create mode 100644 apps/backend/internal/handler/utm/list_utm_links_handler.go create mode 100644 apps/backend/internal/handler/workspaces/add_workspace_member_handler.go create mode 100644 apps/backend/internal/handler/workspaces/approve_draft_review_handler.go create mode 100644 apps/backend/internal/handler/workspaces/archive_workspace_handler.go create mode 100644 apps/backend/internal/handler/workspaces/create_workspace_handler.go create mode 100644 apps/backend/internal/handler/workspaces/export_workspace_report_handler.go create mode 100644 apps/backend/internal/handler/workspaces/list_draft_reviews_handler.go create mode 100644 apps/backend/internal/handler/workspaces/list_workspace_members_handler.go create mode 100644 apps/backend/internal/handler/workspaces/list_workspaces_handler.go create mode 100644 apps/backend/internal/handler/workspaces/reject_draft_review_handler.go create mode 100644 apps/backend/internal/handler/workspaces/remove_workspace_member_handler.go create mode 100644 apps/backend/internal/handler/workspaces/submit_draft_review_handler.go create mode 100644 apps/backend/internal/handler/workspaces/switch_workspace_handler.go create mode 100644 apps/backend/internal/handler/workspaces/update_workspace_branding_handler.go create mode 100644 apps/backend/internal/handler/workspaces/update_workspace_handler.go create mode 100644 apps/backend/internal/logic/benchmark/get_benchmark_logic.go create mode 100644 apps/backend/internal/logic/checkups/generate_checkup_now_logic.go create mode 100644 apps/backend/internal/logic/checkups/get_checkup_logic.go create mode 100644 apps/backend/internal/logic/checkups/get_latest_checkup_logic.go create mode 100644 apps/backend/internal/logic/checkups/list_checkups_logic.go create mode 100644 apps/backend/internal/logic/growthmap/map.go create mode 100644 apps/backend/internal/logic/health/get_account_health_logic.go create mode 100644 apps/backend/internal/logic/health/list_account_health_logic.go create mode 100644 apps/backend/internal/logic/insightsapi/get_insights_summary_logic.go create mode 100644 apps/backend/internal/logic/outcomes/delete_conversion_logic.go create mode 100644 apps/backend/internal/logic/outcomes/get_outcome_logic.go create mode 100644 apps/backend/internal/logic/outcomes/get_outcome_summary_logic.go create mode 100644 apps/backend/internal/logic/outcomes/list_outcomes_logic.go create mode 100644 apps/backend/internal/logic/outcomes/report_conversion_logic.go create mode 100644 apps/backend/internal/logic/outcomes/update_conversion_logic.go create mode 100644 apps/backend/internal/logic/playbooks/get_playbook_logic.go create mode 100644 apps/backend/internal/logic/playbooks/import_playbook_logic.go create mode 100644 apps/backend/internal/logic/playbooks/list_playbooks_logic.go create mode 100644 apps/backend/internal/logic/playbooks/publish_playbook_logic.go create mode 100644 apps/backend/internal/logic/playbooks/remove_playbook_logic.go create mode 100644 apps/backend/internal/logic/publictools/public_pain_keywords_logic.go create mode 100644 apps/backend/internal/logic/publictools/public_style_quiz_logic.go create mode 100644 apps/backend/internal/logic/publicutm/utm_redirect_logic.go create mode 100644 apps/backend/internal/logic/utm/create_utm_link_logic.go create mode 100644 apps/backend/internal/logic/utm/list_utm_links_logic.go create mode 100644 apps/backend/internal/logic/workspaces/add_workspace_member_logic.go create mode 100644 apps/backend/internal/logic/workspaces/approve_draft_review_logic.go create mode 100644 apps/backend/internal/logic/workspaces/archive_workspace_logic.go create mode 100644 apps/backend/internal/logic/workspaces/create_workspace_logic.go create mode 100644 apps/backend/internal/logic/workspaces/export_workspace_report_logic.go create mode 100644 apps/backend/internal/logic/workspaces/list_draft_reviews_logic.go create mode 100644 apps/backend/internal/logic/workspaces/list_workspace_members_logic.go create mode 100644 apps/backend/internal/logic/workspaces/list_workspaces_logic.go create mode 100644 apps/backend/internal/logic/workspaces/reject_draft_review_logic.go create mode 100644 apps/backend/internal/logic/workspaces/remove_workspace_member_logic.go create mode 100644 apps/backend/internal/logic/workspaces/submit_draft_review_logic.go create mode 100644 apps/backend/internal/logic/workspaces/switch_workspace_logic.go create mode 100644 apps/backend/internal/logic/workspaces/update_workspace_branding_logic.go create mode 100644 apps/backend/internal/logic/workspaces/update_workspace_logic.go create mode 100644 apps/backend/internal/module/growth/domain/domain.go create mode 100644 apps/backend/internal/module/growth/repository/memory.go create mode 100644 apps/backend/internal/module/growth/repository/mongo.go create mode 100644 apps/backend/internal/module/growth/usecase/p2.go create mode 100644 apps/backend/internal/module/growth/usecase/service.go create mode 100644 apps/backend/internal/module/growth/usecase/service_test.go create mode 100644 apps/web/src/components/layout/WorkspaceSwitcher.tsx create mode 100644 apps/web/src/data/live/growthRepo.ts create mode 100644 apps/web/src/pages/BenchmarkPage.tsx create mode 100644 apps/web/src/pages/PlaybooksPage.tsx create mode 100644 apps/web/src/pages/UtmLinksPage.tsx create mode 100644 apps/web/src/pages/tools/PainKeywordsPage.tsx create mode 100644 apps/web/src/pages/tools/StyleQuizPage.tsx create mode 100644 docs/product/growth-loop/GAPS.md create mode 100644 docs/product/growth-loop/TESTING.md create mode 100644 docs/product/growth-loop/plan.md create mode 100644 docs/product/growth-loop/spec.md create mode 100644 docs/product/growth-loop/tasks/INDEX.md create mode 100644 docs/product/growth-loop/tasks/T300-api-shells-outcomes-checkups-health-workspaces.md create mode 100644 docs/product/growth-loop/tasks/T301-mongo-indexes-growth-loop.md create mode 100644 docs/product/growth-loop/tasks/T302-fe-domain-types-repo-interfaces.md create mode 100644 docs/product/growth-loop/tasks/T310-outcome-domain-model-repository.md create mode 100644 docs/product/growth-loop/tasks/T311-outcome-hooks-scout-outbox.md create mode 100644 docs/product/growth-loop/tasks/T312-outcome-observe-job.md create mode 100644 docs/product/growth-loop/tasks/T313-outcome-api-summary-list-conversion.md create mode 100644 docs/product/growth-loop/tasks/T314-fe-outcomes-today-usage-scout.md create mode 100644 docs/product/growth-loop/tasks/T315-test-m1-outcomes-integration.md create mode 100644 docs/product/growth-loop/tasks/T330-weekly-checkup-model-job.md create mode 100644 docs/product/growth-loop/tasks/T331-checkup-api-no-usage-debit.md create mode 100644 docs/product/growth-loop/tasks/T332-asset-learning-persona-brand.md create mode 100644 docs/product/growth-loop/tasks/T333-fe-checkup-learning-ui.md create mode 100644 docs/product/growth-loop/tasks/T334-test-m2-checkup-asset-integration.md create mode 100644 docs/product/growth-loop/tasks/T350-account-health-model-recompute.md create mode 100644 docs/product/growth-loop/tasks/T351-health-gate-auto-send-throttle.md create mode 100644 docs/product/growth-loop/tasks/T352-fe-health-badge-today-warn.md create mode 100644 docs/product/growth-loop/tasks/T353-test-m3-health-integration.md create mode 100644 docs/product/growth-loop/tasks/T365-invite-reward-on-first-paid-plan.md create mode 100644 docs/product/growth-loop/tasks/T366-invite-rewards-api-fe.md create mode 100644 docs/product/growth-loop/tasks/T367-test-m4-invite-reward-integration.md create mode 100644 docs/product/growth-loop/tasks/T380-workspace-model-backfill-default.md create mode 100644 docs/product/growth-loop/tasks/T381-workspace-api-resource-filter.md create mode 100644 docs/product/growth-loop/tasks/T382-draft-review-gate-auto-send.md create mode 100644 docs/product/growth-loop/tasks/T383-workspace-monthly-report-pdf.md create mode 100644 docs/product/growth-loop/tasks/T384-fe-workspace-shell-review-export.md create mode 100644 docs/product/growth-loop/tasks/T385-test-m5-workspace-integration.md create mode 100644 docs/product/growth-loop/tasks/T400-test-matrix-p0-p1-acceptance.md create mode 100644 docs/product/growth-loop/tasks/T401-smoke-fe-be-growth-loop.md create mode 100644 docs/product/growth-loop/tasks/T402-gap-doc-p2-and-signal-probe.md diff --git a/apps/backend/cmd/worker/main.go b/apps/backend/cmd/worker/main.go index ac1f6b8..cfbb707 100644 --- a/apps/backend/cmd/worker/main.go +++ b/apps/backend/cmd/worker/main.go @@ -28,6 +28,8 @@ import ( memberRepo "apps/backend/internal/module/member/repository" scoutDomain "apps/backend/internal/module/scout/domain" scoutRepo "apps/backend/internal/module/scout/repository" + growthRepo "apps/backend/internal/module/growth/repository" + growthUC "apps/backend/internal/module/growth/usecase" scoutUC "apps/backend/internal/module/scout/usecase" studioPublish "apps/backend/internal/module/studio/publish" studioRepo "apps/backend/internal/module/studio/repository" @@ -143,7 +145,16 @@ func main() { // worker 執行 job 本體,不經 API 再入列 studio.Jobs = nil - logx.Infof("haixun worker started id=%s interval=%s (demo + token_renew + persona_analyze + compose_mimic + outbox)", workerID, interval) + growthSvc := growthUC.New(growthRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)) + growthSvc.Bonus = usageSvc + scoutSvc.OnOutreachPublished = func(ctx context.Context, ownerUID int64, postID, accountID string) { + _, _ = growthSvc.RecordPublished(ctx, ownerUID, "scout_outreach", postID, accountID, 0) + } + studio.OnStepPublished = func(ctx context.Context, ownerUID int64, bundleID, stepID, accountID string) { + _, _ = growthSvc.RecordPublished(ctx, ownerUID, "outbox_step", bundleID+":"+stepID, accountID, 0) + } + + logx.Infof("haixun worker started id=%s interval=%s (demo + token_renew + persona_analyze + compose_mimic + outbox + growth)", workerID, interval) fmt.Printf("worker running id=%s interval=%s (Ctrl+C to stop)\n", workerID, interval) sig := make(chan os.Signal, 1) @@ -220,7 +231,13 @@ func main() { } // 2) One worker owns an outbox tick and renews its lease while claims run. processOutbox(ctx, studio, outboxLock, workerID, outboxLockTTL) - // 3) 終態任務超過 2 天自動清除 + // 3) growth-loop: expire observing outcomes + light health refresh + if n, err := growthSvc.ObserveTick(ctx, 0); err != nil { + logx.Errorf("worker %s outcome observe: %v", workerID, err) + } else if n > 0 { + logx.Infof("worker %s outcome observe updated %d", workerID, n) + } + // 4) 終態任務超過 2 天自動清除 if purged, err := jobs.PurgeExpiredTerminal(ctx); err != nil { logx.Errorf("worker %s purge jobs: %v", workerID, err) } else if purged > 0 { diff --git a/apps/backend/etc/gateway.example.yaml b/apps/backend/etc/gateway.example.yaml index 153eef0..02ac515 100644 --- a/apps/backend/etc/gateway.example.yaml +++ b/apps/backend/etc/gateway.example.yaml @@ -124,12 +124,12 @@ Mail: DevExposeCode: false # 前端 origin:重設信連結 + 預設 logo URL(/brand-mark.jpg) -# 遠端 dev:https://threads-tool-dev.30cm.net -PublicWebBase: "https://threads-tool-dev.30cm.net" +# 正式遠端:https://threads-tool.30cm.net +PublicWebBase: "https://threads-tool.30cm.net" # 瀏覽器直連 gateway 時的 allowlist;同源 proxy 不需要列在此處。 # CORS_ALLOWED_ORIGINS 可用逗號附加額外的明確 origin,不能使用 *。 CORSAllowedOrigins: - - "https://threads-tool-dev.30cm.net" + - "https://threads-tool.30cm.net" - "http://127.0.0.1:5173" - "http://localhost:5173" @@ -158,5 +158,5 @@ ObjectStorage: SecretKey: "" UsePathStyle: true # env HAIXUN_STORAGE_S3_PUBLIC_BASE_URL 會覆寫;勿用 /api/v1/public/assets(新後端無此 route) - PublicBaseURL: "https://threads-tool-dev.30cm.net/haixun-assets" + PublicBaseURL: "https://threads-tool.30cm.net/haixun-assets" diff --git a/apps/backend/generate/api/gateway.api b/apps/backend/generate/api/gateway.api index 6df0038..1c0bdc1 100644 --- a/apps/backend/generate/api/gateway.api +++ b/apps/backend/generate/api/gateway.api @@ -23,3 +23,5 @@ import "extension.api" import "studio.api" import "m5.api" import "invite.api" +import "growth.api" +import "growth_p2.api" diff --git a/apps/backend/generate/api/growth.api b/apps/backend/generate/api/growth.api new file mode 100644 index 0000000..dbaa764 --- /dev/null +++ b/apps/backend/generate/api/growth.api @@ -0,0 +1,315 @@ +syntax = "v1" + +// growth-loop: outcomes / checkups / account health / workspaces / invite rewards + +type ( + OutcomeEventPublic { + Id string `json:"id"` + SourceType string `json:"source_type"` + SourceId string `json:"source_id"` + ThreadsAccountId string `json:"threads_account_id,optional"` + Kind string `json:"kind"` + Confidence string `json:"confidence"` + Status string `json:"status"` + ReplyCountDelta int `json:"reply_count_delta,optional"` + LikeCountDelta int `json:"like_count_delta,optional"` + FollowersDelta int `json:"followers_delta,optional"` + ConversionAmount float64 `json:"conversion_amount,optional"` + ConversionNote string `json:"conversion_note,optional"` + ConversionCurrency string `json:"conversion_currency,optional"` + WindowEndsAt int64 `json:"window_ends_at"` + SentAt int64 `json:"sent_at"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + + OutcomeSummaryData { + From int64 `json:"from"` + To int64 `json:"to"` + Reach int `json:"reach"` + Conversations int `json:"conversations"` + FollowsPossible int `json:"follows_possible"` + FollowsConfirmed int `json:"follows_confirmed"` + Conversions int `json:"conversions"` + ConversionAmount float64 `json:"conversion_amount"` + } + + ListOutcomesReq { + Page int `form:"page,default=1"` + PageSize int `form:"pageSize,default=20"` + Kind string `form:"kind,optional"` + Confidence string `form:"confidence,optional"` + SourceType string `form:"source_type,optional"` + From int64 `form:"from,optional"` + To int64 `form:"to,optional"` + } + + OutcomeListData { + List []OutcomeEventPublic `json:"list"` + Pagination Pagination `json:"pagination"` + } + + OutcomeSummaryReq { + Range string `form:"range,default=week"` // week | month + From int64 `form:"from,optional"` + To int64 `form:"to,optional"` + } + + GetOutcomeReq { + Id string `path:"id"` + } + + ReportConversionReq { + Id string `path:"id"` + Amount float64 `json:"amount,optional"` + Note string `json:"note,optional"` + Currency string `json:"currency,optional"` + } + + UpdateConversionReq { + Id string `path:"id"` + Amount float64 `json:"amount,optional"` + Note string `json:"note,optional"` + Currency string `json:"currency,optional"` + } + + DeleteConversionReq { + Id string `path:"id"` + } + + CheckupFindingPublic { + Claim string `json:"claim"` + Evidence []string `json:"evidence"` + } + + CheckupActionPublic { + Title string `json:"title"` + Reason string `json:"reason"` + Deeplink string `json:"deeplink"` + } + + WeeklyCheckupPublic { + Id string `json:"id"` + WeekKey string `json:"week_key"` + Status string `json:"status"` + Summary string `json:"summary"` + Findings []CheckupFindingPublic `json:"findings"` + Actions []CheckupActionPublic `json:"actions"` + Error string `json:"error,optional"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + + ListCheckupsReq { + Page int `form:"page,default=1"` + PageSize int `form:"pageSize,default=20"` + } + + CheckupListData { + List []WeeklyCheckupPublic `json:"list"` + Pagination Pagination `json:"pagination"` + } + + GetCheckupReq { + Id string `path:"id"` + } + + AccountHealthPublic { + ThreadsAccountId string `json:"threads_account_id"` + Score int `json:"score"` + Level string `json:"level"` + Factors []string `json:"factors"` + Advice string `json:"advice"` + ComputedAt int64 `json:"computed_at"` + } + + AccountHealthListData { + List []AccountHealthPublic `json:"list"` + } + + GetAccountHealthReq { + AccountId string `path:"accountId"` + } + + WorkspacePublic { + Id string `json:"id"` + Name string `json:"name"` + ReviewRequired bool `json:"review_required"` + IsDefault bool `json:"is_default"` + Archived bool `json:"archived"` + FooterText string `json:"footer_text,optional"` + HidePoweredBy bool `json:"hide_powered_by,optional"` + BrandName string `json:"brand_name,optional"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + + WorkspaceListData { + List []WorkspacePublic `json:"list"` + CurrentWorkspaceId string `json:"current_workspace_id"` + } + + CreateWorkspaceReq { + Name string `json:"name"` + } + + UpdateWorkspaceReq { + Id string `path:"id"` + Name string `json:"name,optional"` + ReviewRequired *bool `json:"review_required,optional"` + } + + WorkspaceIdReq { + Id string `path:"id"` + } + + SwitchWorkspaceReq { + Id string `path:"id"` + } + + DraftReviewPublic { + Id string `json:"id"` + RefType string `json:"ref_type"` + RefId string `json:"ref_id"` + WorkspaceId string `json:"workspace_id"` + Status string `json:"status"` + Reason string `json:"reason,optional"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + + SubmitDraftReviewReq { + RefType string `json:"ref_type"` + RefId string `json:"ref_id"` + WorkspaceId string `json:"workspace_id,optional"` + } + + ReviewDecisionReq { + Id string `path:"id"` + Reason string `json:"reason,optional"` + } + + ListDraftReviewsReq { + Page int `form:"page,default=1"` + PageSize int `form:"pageSize,default=20"` + Status string `form:"status,optional"` + } + + DraftReviewListData { + List []DraftReviewPublic `json:"list"` + Pagination Pagination `json:"pagination"` + } + + ExportReportReq { + WorkspaceId string `form:"workspace_id"` + YearMonth string `form:"year_month"` // YYYY-MM + Format string `form:"format,optional"` // md | pdf + } + + ExportReportData { + Url string `json:"url"` + ExpiresAt int64 `json:"expires_at,optional"` + Filename string `json:"filename"` + // ContentType: text/markdown or application/pdf + ContentType string `json:"content_type,optional"` + // Format: md | pdf + Format string `json:"format,optional"` + } + + // branding fields on workspace (P2 white-label report) + // extended via UpdateWorkspaceBranding in growth_p2.api +) + +@server ( + group: outcomes + prefix: /api/v1/outcomes + middleware: AuthJWT +) +service gateway { + @handler GetOutcomeSummary + get /summary (OutcomeSummaryReq) returns (OutcomeSummaryData) + + @handler ListOutcomes + get / (ListOutcomesReq) returns (OutcomeListData) + + @handler GetOutcome + get /:id (GetOutcomeReq) returns (OutcomeEventPublic) + + @handler ReportConversion + post /:id/conversion (ReportConversionReq) returns (OutcomeEventPublic) + + @handler UpdateConversion + put /:id/conversion (UpdateConversionReq) returns (OutcomeEventPublic) + + @handler DeleteConversion + delete /:id/conversion (DeleteConversionReq) returns (OkData) +} + +@server ( + group: checkups + prefix: /api/v1/checkups + middleware: AuthJWT +) +service gateway { + @handler GetLatestCheckup + get /latest returns (WeeklyCheckupPublic) + + @handler ListCheckups + get / (ListCheckupsReq) returns (CheckupListData) + + @handler GetCheckup + get /:id (GetCheckupReq) returns (WeeklyCheckupPublic) + + @handler GenerateCheckupNow + post /generate returns (WeeklyCheckupPublic) +} + +@server ( + group: health + prefix: /api/v1/account-health + middleware: AuthJWT +) +service gateway { + @handler ListAccountHealth + get / returns (AccountHealthListData) + + @handler GetAccountHealth + get /:accountId (GetAccountHealthReq) returns (AccountHealthPublic) +} + +@server ( + group: workspaces + prefix: /api/v1/workspaces + middleware: AuthJWT +) +service gateway { + @handler ListWorkspaces + get / returns (WorkspaceListData) + + @handler CreateWorkspace + post / (CreateWorkspaceReq) returns (WorkspacePublic) + + @handler UpdateWorkspace + put /:id (UpdateWorkspaceReq) returns (WorkspacePublic) + + @handler ArchiveWorkspace + post /:id/archive (WorkspaceIdReq) returns (OkData) + + @handler SwitchWorkspace + post /:id/switch (SwitchWorkspaceReq) returns (WorkspaceListData) + + @handler SubmitDraftReview + post /reviews (SubmitDraftReviewReq) returns (DraftReviewPublic) + + @handler ApproveDraftReview + post /reviews/:id/approve (ReviewDecisionReq) returns (DraftReviewPublic) + + @handler RejectDraftReview + post /reviews/:id/reject (ReviewDecisionReq) returns (DraftReviewPublic) + + @handler ListDraftReviews + get /reviews (ListDraftReviewsReq) returns (DraftReviewListData) + + @handler ExportWorkspaceReport + get /report/export (ExportReportReq) returns (ExportReportData) +} diff --git a/apps/backend/generate/api/growth_p2.api b/apps/backend/generate/api/growth_p2.api new file mode 100644 index 0000000..5a774fb --- /dev/null +++ b/apps/backend/generate/api/growth_p2.api @@ -0,0 +1,285 @@ +syntax = "v1" + +// growth-loop P2: playbook / free tools / benchmark / utm / insights / workspace members + +type ( + PlaybookPublic { + Id string `json:"id"` + Kind string `json:"kind"` // brief | persona | play + Title string `json:"title"` + Niche string `json:"niche"` + Body string `json:"body"` + Anonymous bool `json:"anonymous"` + AuthorLabel string `json:"author_label"` + ImportCount int `json:"import_count"` + CreatedAt int64 `json:"created_at"` + Mine bool `json:"mine,optional"` + } + + PlaybookListData { + List []PlaybookPublic `json:"list"` + Pagination Pagination `json:"pagination"` + } + + ListPlaybooksReq { + Page int `form:"page,default=1"` + PageSize int `form:"pageSize,default=20"` + Kind string `form:"kind,optional"` + Niche string `form:"niche,optional"` + Mine bool `form:"mine,optional"` + } + + PublishPlaybookReq { + Kind string `json:"kind"` + Title string `json:"title"` + Niche string `json:"niche,optional"` + Body string `json:"body"` + Anonymous bool `json:"anonymous,optional"` + } + + PlaybookIdPath { + Id string `path:"id"` + } + + ImportPlaybookData { + Id string `json:"id"` + Copied bool `json:"copied"` + Message string `json:"message"` + } + + StyleQuizReq { + Samples string `json:"samples"` + } + + StyleQuizData { + Tone string `json:"tone"` + Rhythm string `json:"rhythm"` + Hooks string `json:"hooks"` + Avoid string `json:"avoid"` + Summary string `json:"summary"` + Tags []string `json:"tags"` + SignupHint string `json:"signup_hint"` + } + + PainKeywordsReq { + ProductBrief string `json:"product_brief"` + Audience string `json:"audience,optional"` + } + + PainKeywordsData { + Keywords []string `json:"keywords"` + Pains []string `json:"pains"` + ScanTerms []string `json:"scan_terms"` + Summary string `json:"summary"` + SignupHint string `json:"signup_hint"` + } + + BenchmarkReq { + Niche string `form:"niche,optional"` + AccountId string `form:"account_id,optional"` + } + + BenchmarkData { + Available bool `json:"available"` + SampleSize int `json:"sample_size"` + Niche string `json:"niche"` + MedianEngRate float64 `json:"median_eng_rate"` + MedianViews float64 `json:"median_views"` + YourEngRate float64 `json:"your_eng_rate,optional"` + YourViews float64 `json:"your_views,optional"` + PercentileHint string `json:"percentile_hint,optional"` + Message string `json:"message,optional"` + } + + CreateUtmLinkReq { + DestinationUrl string `json:"destination_url"` + OutcomeId string `json:"outcome_id,optional"` + Label string `json:"label,optional"` + } + + UtmLinkPublic { + Id string `json:"id"` + Code string `json:"code"` + TrackPath string `json:"track_path"` + DestinationUrl string `json:"destination_url"` + OutcomeId string `json:"outcome_id,optional"` + Label string `json:"label,optional"` + Clicks int `json:"clicks"` + CreatedAt int64 `json:"created_at"` + } + + UtmLinkListData { + List []UtmLinkPublic `json:"list"` + } + + UtmRedirectReq { + Code string `path:"code"` + } + + InsightsSummaryReq { + AccountId string `form:"account_id,optional"` + Months int `form:"months,default=3"` + } + + InsightsPostPublic { + Id string `json:"id"` + TextPreview string `json:"text_preview"` + PublishedAt int64 `json:"published_at"` + ViewCount int `json:"view_count"` + LikeCount int `json:"like_count"` + ReplyCount int `json:"reply_count"` + EngRate float64 `json:"eng_rate"` + } + + InsightsSummaryData { + AccountId string `json:"account_id,optional"` + PostCount int `json:"post_count"` + TotalViews int `json:"total_views"` + TotalLikes int `json:"total_likes"` + TotalReplies int `json:"total_replies"` + AvgEngRate float64 `json:"avg_eng_rate"` + TopPosts []InsightsPostPublic `json:"top_posts"` + MonthBuckets []InsightsMonthBucket `json:"month_buckets"` + Conclusion string `json:"conclusion"` + Suggestions []string `json:"suggestions"` + } + + InsightsMonthBucket { + MonthKey string `json:"month_key"` + Posts int `json:"posts"` + Views int `json:"views"` + Likes int `json:"likes"` + Replies int `json:"replies"` + AvgEngRate float64 `json:"avg_eng_rate"` + } + + AddWorkspaceMemberReq { + WorkspaceId string `path:"id"` + MemberUid int64 `json:"member_uid,optional"` + Email string `json:"email,optional"` + Role string `json:"role,optional"` // editor | reviewer | owner + } + + WorkspaceMemberPublic { + Uid int64 `json:"uid"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + JoinedAt int64 `json:"joined_at"` + } + + WorkspaceMemberListData { + List []WorkspaceMemberPublic `json:"list"` + } + + ListWorkspaceMembersReq { + Id string `path:"id"` + } + + RemoveWorkspaceMemberReq { + Id string `path:"id"` + Uid int64 `path:"uid"` + } + + WorkspaceBrandingReq { + Id string `path:"id"` + FooterText string `json:"footer_text,optional"` + HidePoweredBy bool `json:"hide_powered_by,optional"` + BrandName string `json:"brand_name,optional"` + } +) + +@server ( + group: playbooks + prefix: /api/v1/playbooks + middleware: AuthJWT +) +service gateway { + @handler ListPlaybooks + get / (ListPlaybooksReq) returns (PlaybookListData) + + @handler PublishPlaybook + post / (PublishPlaybookReq) returns (PlaybookPublic) + + @handler GetPlaybook + get /:id (PlaybookIdPath) returns (PlaybookPublic) + + @handler ImportPlaybook + post /:id/import (PlaybookIdPath) returns (ImportPlaybookData) + + @handler RemovePlaybook + delete /:id (PlaybookIdPath) returns (OkData) +} + +@server ( + group: publictools + prefix: /api/v1/public/tools +) +service gateway { + @handler PublicStyleQuiz + post /style-quiz (StyleQuizReq) returns (StyleQuizData) + + @handler PublicPainKeywords + post /pain-keywords (PainKeywordsReq) returns (PainKeywordsData) +} + +@server ( + group: benchmark + prefix: /api/v1/benchmark + middleware: AuthJWT +) +service gateway { + @handler GetBenchmark + get / (BenchmarkReq) returns (BenchmarkData) +} + +@server ( + group: utm + prefix: /api/v1/utm + middleware: AuthJWT +) +service gateway { + @handler CreateUtmLink + post /links (CreateUtmLinkReq) returns (UtmLinkPublic) + + @handler ListUtmLinks + get /links returns (UtmLinkListData) +} + +@server ( + group: publicutm + prefix: /api/v1/public/u +) +service gateway { + @handler UtmRedirect + get /:code (UtmRedirectReq) returns (OkData) +} + +@server ( + group: insightsapi + prefix: /api/v1/insights + middleware: AuthJWT +) +service gateway { + @handler GetInsightsSummary + get /summary (InsightsSummaryReq) returns (InsightsSummaryData) +} + +@server ( + group: workspaces + prefix: /api/v1/workspaces + middleware: AuthJWT +) +service gateway { + @handler ListWorkspaceMembers + get /:id/members (ListWorkspaceMembersReq) returns (WorkspaceMemberListData) + + @handler AddWorkspaceMember + post /:id/members (AddWorkspaceMemberReq) returns (WorkspaceMemberPublic) + + @handler RemoveWorkspaceMember + delete /:id/members/:uid (RemoveWorkspaceMemberReq) returns (OkData) + + @handler UpdateWorkspaceBranding + put /:id/branding (WorkspaceBrandingReq) returns (WorkspacePublic) +} diff --git a/apps/backend/generate/api/invite.api b/apps/backend/generate/api/invite.api index 96b1431..789b7a7 100644 --- a/apps/backend/generate/api/invite.api +++ b/apps/backend/generate/api/invite.api @@ -34,11 +34,28 @@ type ( Children []InviteTreeNode `json:"children"` } + InviteRewardPublic { + Id string `json:"id"` + InviteeUid int64 `json:"invitee_uid"` + PlanId string `json:"plan_id"` + Points int `json:"points"` + Status string `json:"status"` + CreatedAt int64 `json:"created_at"` + } + + InviteRewardsSummary { + TotalPoints int `json:"total_points"` + MonthPoints int `json:"month_points"` + MonthCap int `json:"month_cap"` + Recent []InviteRewardPublic `json:"recent"` + } + MyInviteNetworkData { Me InviteMemberBrief `json:"me"` Upline *InviteMemberBrief `json:"upline,optional"` Downlines []InviteMemberBrief `json:"downlines"` TotalDownlineCount int `json:"total_downline_count"` + Rewards *InviteRewardsSummary `json:"rewards,optional"` } ApplyInviteCodeReq { diff --git a/apps/backend/generate/api/m5.api b/apps/backend/generate/api/m5.api index 86b0696..36cc1e1 100644 --- a/apps/backend/generate/api/m5.api +++ b/apps/backend/generate/api/m5.api @@ -152,11 +152,15 @@ type ( // --- Scout --- BrandPublic { - Id string `json:"id"` - DisplayName string `json:"display_name"` - Brief string `json:"brief"` - TargetAudience string `json:"target_audience,optional"` - Goals string `json:"goals,optional"` + Id string `json:"id"` + DisplayName string `json:"display_name"` + Brief string `json:"brief"` + TargetAudience string `json:"target_audience,optional"` + Goals string `json:"goals,optional"` + LearningSummary string `json:"learning_summary,optional"` + LearningVersion int `json:"learning_version,optional"` + LearnedFromPostsCount int `json:"learned_from_posts_count,optional"` + LastLearnedAt int64 `json:"last_learned_at,optional"` } BrandListData { List []BrandPublic `json:"list"` diff --git a/apps/backend/generate/api/studio.api b/apps/backend/generate/api/studio.api index 43e2c9a..b51708c 100644 --- a/apps/backend/generate/api/studio.api +++ b/apps/backend/generate/api/studio.api @@ -43,16 +43,20 @@ type ( } PersonaPublic { - Id string `json:"id"` - Name string `json:"name"` - Brief string `json:"brief"` - Status string `json:"status"` - Style PersonaStylePublic `json:"style"` - Guard PersonaGuardPublic `json:"guard"` - Voice string `json:"voice,optional"` - Notes string `json:"notes,optional"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + Id string `json:"id"` + Name string `json:"name"` + Brief string `json:"brief"` + Status string `json:"status"` + Style PersonaStylePublic `json:"style"` + Guard PersonaGuardPublic `json:"guard"` + Voice string `json:"voice,optional"` + Notes string `json:"notes,optional"` + LearningSummary string `json:"learning_summary,optional"` + LearningVersion int `json:"learning_version,optional"` + LearnedFromPostsCount int `json:"learned_from_posts_count,optional"` + LastLearnedAt int64 `json:"last_learned_at,optional"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } PersonaListData { diff --git a/apps/backend/generate/database/mongo/000013_growth_loop_indexes.down.json b/apps/backend/generate/database/mongo/000013_growth_loop_indexes.down.json new file mode 100644 index 0000000..a6fa11d --- /dev/null +++ b/apps/backend/generate/database/mongo/000013_growth_loop_indexes.down.json @@ -0,0 +1,12 @@ +[ + { "dropIndexes": "growth_outcomes", "index": "owner_outcomes_recent" }, + { "dropIndexes": "growth_outcomes", "index": "owner_outcome_source" }, + { "dropIndexes": "growth_outcomes", "index": "outcome_observe" }, + { "dropIndexes": "growth_checkups", "index": "owner_checkups_recent" }, + { "dropIndexes": "growth_checkups", "index": "owner_checkup_week" }, + { "dropIndexes": "growth_account_health", "index": "owner_health" }, + { "dropIndexes": "growth_workspaces", "index": "owner_workspaces" }, + { "dropIndexes": "growth_draft_reviews", "index": "owner_review_ref" }, + { "dropIndexes": "growth_invite_rewards", "index": "inviter_rewards" }, + { "dropIndexes": "growth_invite_rewards", "index": "invitee_reward_once" } +] diff --git a/apps/backend/generate/database/mongo/000013_growth_loop_indexes.up.json b/apps/backend/generate/database/mongo/000013_growth_loop_indexes.up.json new file mode 100644 index 0000000..b9db948 --- /dev/null +++ b/apps/backend/generate/database/mongo/000013_growth_loop_indexes.up.json @@ -0,0 +1,42 @@ +[ + { + "createIndexes": "growth_outcomes", + "indexes": [ + { "key": { "owner_uid": 1, "sent_at": -1 }, "name": "owner_outcomes_recent" }, + { "key": { "owner_uid": 1, "source_type": 1, "source_id": 1 }, "name": "owner_outcome_source", "unique": true }, + { "key": { "status": 1, "window_ends_at": 1 }, "name": "outcome_observe" } + ] + }, + { + "createIndexes": "growth_checkups", + "indexes": [ + { "key": { "owner_uid": 1, "created_at": -1 }, "name": "owner_checkups_recent" }, + { "key": { "owner_uid": 1, "week_key": 1 }, "name": "owner_checkup_week" } + ] + }, + { + "createIndexes": "growth_account_health", + "indexes": [ + { "key": { "owner_uid": 1 }, "name": "owner_health" } + ] + }, + { + "createIndexes": "growth_workspaces", + "indexes": [ + { "key": { "owner_uid": 1, "archived": 1 }, "name": "owner_workspaces" } + ] + }, + { + "createIndexes": "growth_draft_reviews", + "indexes": [ + { "key": { "owner_uid": 1, "ref_type": 1, "ref_id": 1, "updated_at": -1 }, "name": "owner_review_ref" } + ] + }, + { + "createIndexes": "growth_invite_rewards", + "indexes": [ + { "key": { "inviter_uid": 1, "created_at": -1 }, "name": "inviter_rewards" }, + { "key": { "invitee_uid": 1 }, "name": "invitee_reward_once" } + ] + } +] diff --git a/apps/backend/internal/config/config.go b/apps/backend/internal/config/config.go index 0edd4d7..5949aec 100644 --- a/apps/backend/internal/config/config.go +++ b/apps/backend/internal/config/config.go @@ -101,11 +101,11 @@ type Config struct { DevExposeCode bool `json:",default=true"` } `json:",optional"` // PublicWebBase — 前端 origin,用於重設密碼信內連結/logo(勿尾斜線) - // 例:http://127.0.0.1:5173 或 https://threads-tool-dev.30cm.net + // 例:http://127.0.0.1:5173 或 https://threads-tool.30cm.net PublicWebBase string `json:",optional,default=http://127.0.0.1:5173"` // PublicAPIBase — 對外 API origin(Threads OAuth redirect_uri 必須 https 公開網址) // 空則:若 PublicWebBase 為 https 則同 host;否則回落 http://127.0.0.1:Port - // 例:https://threads-tool-dev.30cm.net + // 例:https://threads-tool.30cm.net PublicAPIBase string `json:",optional"` // Brand — 郵件 chrome(hermes);LogoURL 空則用 PublicWebBase + /brand-mark.svg Brand struct { diff --git a/apps/backend/internal/handler/benchmark/get_benchmark_handler.go b/apps/backend/internal/handler/benchmark/get_benchmark_handler.go new file mode 100644 index 0000000..da0f1fc --- /dev/null +++ b/apps/backend/internal/handler/benchmark/get_benchmark_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package benchmark + +import ( + "net/http" + + "apps/backend/internal/logic/benchmark" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetBenchmarkHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.BenchmarkReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := benchmark.NewGetBenchmarkLogic(r.Context(), svcCtx) + data, err := l.GetBenchmark(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/checkups/generate_checkup_now_handler.go b/apps/backend/internal/handler/checkups/generate_checkup_now_handler.go new file mode 100644 index 0000000..65deb00 --- /dev/null +++ b/apps/backend/internal/handler/checkups/generate_checkup_now_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package checkups + +import ( + "net/http" + + "apps/backend/internal/logic/checkups" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func GenerateCheckupNowHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := checkups.NewGenerateCheckupNowLogic(r.Context(), svcCtx) + data, err := l.GenerateCheckupNow() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/checkups/get_checkup_handler.go b/apps/backend/internal/handler/checkups/get_checkup_handler.go new file mode 100644 index 0000000..46d012d --- /dev/null +++ b/apps/backend/internal/handler/checkups/get_checkup_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package checkups + +import ( + "net/http" + + "apps/backend/internal/logic/checkups" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetCheckupHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.GetCheckupReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := checkups.NewGetCheckupLogic(r.Context(), svcCtx) + data, err := l.GetCheckup(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/checkups/get_latest_checkup_handler.go b/apps/backend/internal/handler/checkups/get_latest_checkup_handler.go new file mode 100644 index 0000000..f8e03a0 --- /dev/null +++ b/apps/backend/internal/handler/checkups/get_latest_checkup_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package checkups + +import ( + "net/http" + + "apps/backend/internal/logic/checkups" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func GetLatestCheckupHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := checkups.NewGetLatestCheckupLogic(r.Context(), svcCtx) + data, err := l.GetLatestCheckup() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/checkups/list_checkups_handler.go b/apps/backend/internal/handler/checkups/list_checkups_handler.go new file mode 100644 index 0000000..497a0d9 --- /dev/null +++ b/apps/backend/internal/handler/checkups/list_checkups_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package checkups + +import ( + "net/http" + + "apps/backend/internal/logic/checkups" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListCheckupsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ListCheckupsReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := checkups.NewListCheckupsLogic(r.Context(), svcCtx) + data, err := l.ListCheckups(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/health/get_account_health_handler.go b/apps/backend/internal/handler/health/get_account_health_handler.go new file mode 100644 index 0000000..3c27862 --- /dev/null +++ b/apps/backend/internal/handler/health/get_account_health_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package health + +import ( + "net/http" + + "apps/backend/internal/logic/health" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetAccountHealthHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.GetAccountHealthReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := health.NewGetAccountHealthLogic(r.Context(), svcCtx) + data, err := l.GetAccountHealth(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/health/list_account_health_handler.go b/apps/backend/internal/handler/health/list_account_health_handler.go new file mode 100644 index 0000000..3f982ed --- /dev/null +++ b/apps/backend/internal/handler/health/list_account_health_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package health + +import ( + "net/http" + + "apps/backend/internal/logic/health" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ListAccountHealthHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := health.NewListAccountHealthLogic(r.Context(), svcCtx) + data, err := l.ListAccountHealth() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/insightsapi/get_insights_summary_handler.go b/apps/backend/internal/handler/insightsapi/get_insights_summary_handler.go new file mode 100644 index 0000000..285d199 --- /dev/null +++ b/apps/backend/internal/handler/insightsapi/get_insights_summary_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package insightsapi + +import ( + "net/http" + + "apps/backend/internal/logic/insightsapi" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetInsightsSummaryHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.InsightsSummaryReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := insightsapi.NewGetInsightsSummaryLogic(r.Context(), svcCtx) + data, err := l.GetInsightsSummary(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/outcomes/delete_conversion_handler.go b/apps/backend/internal/handler/outcomes/delete_conversion_handler.go new file mode 100644 index 0000000..1bf4d55 --- /dev/null +++ b/apps/backend/internal/handler/outcomes/delete_conversion_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package outcomes + +import ( + "net/http" + + "apps/backend/internal/logic/outcomes" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func DeleteConversionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.DeleteConversionReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := outcomes.NewDeleteConversionLogic(r.Context(), svcCtx) + data, err := l.DeleteConversion(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/outcomes/get_outcome_handler.go b/apps/backend/internal/handler/outcomes/get_outcome_handler.go new file mode 100644 index 0000000..a35e737 --- /dev/null +++ b/apps/backend/internal/handler/outcomes/get_outcome_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package outcomes + +import ( + "net/http" + + "apps/backend/internal/logic/outcomes" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetOutcomeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.GetOutcomeReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := outcomes.NewGetOutcomeLogic(r.Context(), svcCtx) + data, err := l.GetOutcome(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/outcomes/get_outcome_summary_handler.go b/apps/backend/internal/handler/outcomes/get_outcome_summary_handler.go new file mode 100644 index 0000000..99f5dc9 --- /dev/null +++ b/apps/backend/internal/handler/outcomes/get_outcome_summary_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package outcomes + +import ( + "net/http" + + "apps/backend/internal/logic/outcomes" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetOutcomeSummaryHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.OutcomeSummaryReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := outcomes.NewGetOutcomeSummaryLogic(r.Context(), svcCtx) + data, err := l.GetOutcomeSummary(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/outcomes/list_outcomes_handler.go b/apps/backend/internal/handler/outcomes/list_outcomes_handler.go new file mode 100644 index 0000000..51d7ee6 --- /dev/null +++ b/apps/backend/internal/handler/outcomes/list_outcomes_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package outcomes + +import ( + "net/http" + + "apps/backend/internal/logic/outcomes" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListOutcomesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ListOutcomesReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := outcomes.NewListOutcomesLogic(r.Context(), svcCtx) + data, err := l.ListOutcomes(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/outcomes/report_conversion_handler.go b/apps/backend/internal/handler/outcomes/report_conversion_handler.go new file mode 100644 index 0000000..fe47af7 --- /dev/null +++ b/apps/backend/internal/handler/outcomes/report_conversion_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package outcomes + +import ( + "net/http" + + "apps/backend/internal/logic/outcomes" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ReportConversionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ReportConversionReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := outcomes.NewReportConversionLogic(r.Context(), svcCtx) + data, err := l.ReportConversion(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/outcomes/update_conversion_handler.go b/apps/backend/internal/handler/outcomes/update_conversion_handler.go new file mode 100644 index 0000000..5621a3e --- /dev/null +++ b/apps/backend/internal/handler/outcomes/update_conversion_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package outcomes + +import ( + "net/http" + + "apps/backend/internal/logic/outcomes" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func UpdateConversionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UpdateConversionReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := outcomes.NewUpdateConversionLogic(r.Context(), svcCtx) + data, err := l.UpdateConversion(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/playbooks/get_playbook_handler.go b/apps/backend/internal/handler/playbooks/get_playbook_handler.go new file mode 100644 index 0000000..53b7e1a --- /dev/null +++ b/apps/backend/internal/handler/playbooks/get_playbook_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package playbooks + +import ( + "net/http" + + "apps/backend/internal/logic/playbooks" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetPlaybookHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PlaybookIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := playbooks.NewGetPlaybookLogic(r.Context(), svcCtx) + data, err := l.GetPlaybook(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/playbooks/import_playbook_handler.go b/apps/backend/internal/handler/playbooks/import_playbook_handler.go new file mode 100644 index 0000000..a71048c --- /dev/null +++ b/apps/backend/internal/handler/playbooks/import_playbook_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package playbooks + +import ( + "net/http" + + "apps/backend/internal/logic/playbooks" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ImportPlaybookHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PlaybookIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := playbooks.NewImportPlaybookLogic(r.Context(), svcCtx) + data, err := l.ImportPlaybook(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/playbooks/list_playbooks_handler.go b/apps/backend/internal/handler/playbooks/list_playbooks_handler.go new file mode 100644 index 0000000..25932b8 --- /dev/null +++ b/apps/backend/internal/handler/playbooks/list_playbooks_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package playbooks + +import ( + "net/http" + + "apps/backend/internal/logic/playbooks" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListPlaybooksHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ListPlaybooksReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := playbooks.NewListPlaybooksLogic(r.Context(), svcCtx) + data, err := l.ListPlaybooks(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/playbooks/publish_playbook_handler.go b/apps/backend/internal/handler/playbooks/publish_playbook_handler.go new file mode 100644 index 0000000..c88ad2d --- /dev/null +++ b/apps/backend/internal/handler/playbooks/publish_playbook_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package playbooks + +import ( + "net/http" + + "apps/backend/internal/logic/playbooks" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func PublishPlaybookHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PublishPlaybookReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := playbooks.NewPublishPlaybookLogic(r.Context(), svcCtx) + data, err := l.PublishPlaybook(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/playbooks/remove_playbook_handler.go b/apps/backend/internal/handler/playbooks/remove_playbook_handler.go new file mode 100644 index 0000000..e786010 --- /dev/null +++ b/apps/backend/internal/handler/playbooks/remove_playbook_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package playbooks + +import ( + "net/http" + + "apps/backend/internal/logic/playbooks" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RemovePlaybookHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PlaybookIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := playbooks.NewRemovePlaybookLogic(r.Context(), svcCtx) + data, err := l.RemovePlaybook(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/publictools/public_pain_keywords_handler.go b/apps/backend/internal/handler/publictools/public_pain_keywords_handler.go new file mode 100644 index 0000000..d0c2bdd --- /dev/null +++ b/apps/backend/internal/handler/publictools/public_pain_keywords_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package publictools + +import ( + "net/http" + + "apps/backend/internal/logic/publictools" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func PublicPainKeywordsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PainKeywordsReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := publictools.NewPublicPainKeywordsLogic(r.Context(), svcCtx) + data, err := l.PublicPainKeywords(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/publictools/public_style_quiz_handler.go b/apps/backend/internal/handler/publictools/public_style_quiz_handler.go new file mode 100644 index 0000000..3a9d855 --- /dev/null +++ b/apps/backend/internal/handler/publictools/public_style_quiz_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package publictools + +import ( + "net/http" + + "apps/backend/internal/logic/publictools" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func PublicStyleQuizHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.StyleQuizReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := publictools.NewPublicStyleQuizLogic(r.Context(), svcCtx) + data, err := l.PublicStyleQuiz(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/publicutm/utm_redirect_handler.go b/apps/backend/internal/handler/publicutm/utm_redirect_handler.go new file mode 100644 index 0000000..71af0f4 --- /dev/null +++ b/apps/backend/internal/handler/publicutm/utm_redirect_handler.go @@ -0,0 +1,33 @@ +// Code generated by goctl. DO NOT EDIT. +// customized: HTTP 302 redirect for UTM tracking + +package publicutm + +import ( + "net/http" + + "apps/backend/internal/logic/publicutm" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func UtmRedirectHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UtmRedirectReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := publicutm.NewUtmRedirectLogic(r.Context(), svcCtx) + dest, err := svcCtx.Growth.TrackUtmClick(r.Context(), req.Code) + if err != nil { + response.Write(r.Context(), w, nil, err) + return + } + _ = l + http.Redirect(w, r, dest, http.StatusFound) + } +} diff --git a/apps/backend/internal/handler/routes.go b/apps/backend/internal/handler/routes.go index 69a3688..176971b 100644 --- a/apps/backend/internal/handler/routes.go +++ b/apps/backend/internal/handler/routes.go @@ -8,9 +8,13 @@ import ( admin "apps/backend/internal/handler/admin" auth "apps/backend/internal/handler/auth" + benchmark "apps/backend/internal/handler/benchmark" billing "apps/backend/internal/handler/billing" + checkups "apps/backend/internal/handler/checkups" compose "apps/backend/internal/handler/compose" extension "apps/backend/internal/handler/extension" + health "apps/backend/internal/handler/health" + insightsapi "apps/backend/internal/handler/insightsapi" inspire "apps/backend/internal/handler/inspire" invite "apps/backend/internal/handler/invite" jobs "apps/backend/internal/handler/jobs" @@ -18,16 +22,22 @@ import ( mentions "apps/backend/internal/handler/mentions" notifications "apps/backend/internal/handler/notifications" outbox "apps/backend/internal/handler/outbox" + outcomes "apps/backend/internal/handler/outcomes" ownposts "apps/backend/internal/handler/ownposts" personas "apps/backend/internal/handler/personas" ping "apps/backend/internal/handler/ping" + playbooks "apps/backend/internal/handler/playbooks" plays "apps/backend/internal/handler/plays" proxy "apps/backend/internal/handler/proxy" + publictools "apps/backend/internal/handler/publictools" + publicutm "apps/backend/internal/handler/publicutm" research "apps/backend/internal/handler/research" scout "apps/backend/internal/handler/scout" settings "apps/backend/internal/handler/settings" threads "apps/backend/internal/handler/threads" usage "apps/backend/internal/handler/usage" + utm "apps/backend/internal/handler/utm" + workspaces "apps/backend/internal/handler/workspaces" "apps/backend/internal/svc" "github.com/zeromicro/go-zero/rest" @@ -188,6 +198,20 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { rest.WithPrefix("/api/v1/auth"), ) + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/", + Handler: benchmark.GetBenchmarkHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/benchmark"), + ) + server.AddRoutes( rest.WithMiddlewares( []rest.Middleware{serverCtx.AuthJWT}, @@ -232,6 +256,35 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { rest.WithPrefix("/api/v1/billing"), ) + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/", + Handler: checkups.ListCheckupsHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id", + Handler: checkups.GetCheckupHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/generate", + Handler: checkups.GenerateCheckupNowHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/latest", + Handler: checkups.GetLatestCheckupHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/checkups"), + ) + server.AddRoutes( rest.WithMiddlewares( []rest.Middleware{serverCtx.AuthJWT}, @@ -277,6 +330,39 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { rest.WithPrefix("/api/v1/extension"), ) + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/", + Handler: health.ListAccountHealthHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:accountId", + Handler: health.GetAccountHealthHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/account-health"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/summary", + Handler: insightsapi.GetInsightsSummaryHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/insights"), + ) + server.AddRoutes( rest.WithMiddlewares( []rest.Middleware{serverCtx.AuthJWT}, @@ -446,11 +532,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { []rest.Route{ { Method: http.MethodPost, - Path: "/upload", - Handler: media.UploadHandler(serverCtx), + Path: "/generate-image", + Handler: media.GenerateImageHandler(serverCtx), }, }..., ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), rest.WithPrefix("/api/v1/media"), ) @@ -460,12 +547,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { []rest.Route{ { Method: http.MethodPost, - Path: "/generate-image", - Handler: media.GenerateImageHandler(serverCtx), + Path: "/upload", + Handler: media.UploadHandler(serverCtx), }, }..., ), - rest.WithJwt(serverCtx.Config.Auth.AccessSecret), rest.WithPrefix("/api/v1/media"), ) @@ -564,6 +650,45 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { rest.WithPrefix("/api/v1/outbox"), ) + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/", + Handler: outcomes.ListOutcomesHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id", + Handler: outcomes.GetOutcomeHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/conversion", + Handler: outcomes.ReportConversionHandler(serverCtx), + }, + { + Method: http.MethodPut, + Path: "/:id/conversion", + Handler: outcomes.UpdateConversionHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/:id/conversion", + Handler: outcomes.DeleteConversionHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/summary", + Handler: outcomes.GetOutcomeSummaryHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/outcomes"), + ) + server.AddRoutes( rest.WithMiddlewares( []rest.Middleware{serverCtx.AuthJWT}, @@ -675,6 +800,40 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { rest.WithPrefix("/api/v1"), ) + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/", + Handler: playbooks.ListPlaybooksHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/", + Handler: playbooks.PublishPlaybookHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id", + Handler: playbooks.GetPlaybookHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/:id", + Handler: playbooks.RemovePlaybookHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/import", + Handler: playbooks.ImportPlaybookHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/playbooks"), + ) + server.AddRoutes( rest.WithMiddlewares( []rest.Middleware{serverCtx.AuthJWT}, @@ -755,6 +914,33 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { rest.WithPrefix("/api/v1/proxy"), ) + server.AddRoutes( + []rest.Route{ + { + Method: http.MethodPost, + Path: "/pain-keywords", + Handler: publictools.PublicPainKeywordsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/style-quiz", + Handler: publictools.PublicStyleQuizHandler(serverCtx), + }, + }, + rest.WithPrefix("/api/v1/public/tools"), + ) + + server.AddRoutes( + []rest.Route{ + { + Method: http.MethodGet, + Path: "/:code", + Handler: publicutm.UtmRedirectHandler(serverCtx), + }, + }, + rest.WithPrefix("/api/v1/public/u"), + ) + server.AddRoutes( rest.WithMiddlewares( []rest.Middleware{serverCtx.AuthJWT}, @@ -1059,4 +1245,111 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { rest.WithJwt(serverCtx.Config.Auth.AccessSecret), rest.WithPrefix("/api/v1/usage"), ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodPost, + Path: "/links", + Handler: utm.CreateUtmLinkHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/links", + Handler: utm.ListUtmLinksHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/utm"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodPut, + Path: "/:id/branding", + Handler: workspaces.UpdateWorkspaceBrandingHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/members", + Handler: workspaces.ListWorkspaceMembersHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/members", + Handler: workspaces.AddWorkspaceMemberHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/:id/members/:uid", + Handler: workspaces.RemoveWorkspaceMemberHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/workspaces"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/", + Handler: workspaces.ListWorkspacesHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/", + Handler: workspaces.CreateWorkspaceHandler(serverCtx), + }, + { + Method: http.MethodPut, + Path: "/:id", + Handler: workspaces.UpdateWorkspaceHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/archive", + Handler: workspaces.ArchiveWorkspaceHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/switch", + Handler: workspaces.SwitchWorkspaceHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/report/export", + Handler: workspaces.ExportWorkspaceReportHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/reviews", + Handler: workspaces.SubmitDraftReviewHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/reviews", + Handler: workspaces.ListDraftReviewsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/reviews/:id/approve", + Handler: workspaces.ApproveDraftReviewHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/reviews/:id/reject", + Handler: workspaces.RejectDraftReviewHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/workspaces"), + ) } diff --git a/apps/backend/internal/handler/utm/create_utm_link_handler.go b/apps/backend/internal/handler/utm/create_utm_link_handler.go new file mode 100644 index 0000000..c0163dd --- /dev/null +++ b/apps/backend/internal/handler/utm/create_utm_link_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package utm + +import ( + "net/http" + + "apps/backend/internal/logic/utm" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func CreateUtmLinkHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CreateUtmLinkReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := utm.NewCreateUtmLinkLogic(r.Context(), svcCtx) + data, err := l.CreateUtmLink(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/utm/list_utm_links_handler.go b/apps/backend/internal/handler/utm/list_utm_links_handler.go new file mode 100644 index 0000000..48c3c0b --- /dev/null +++ b/apps/backend/internal/handler/utm/list_utm_links_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package utm + +import ( + "net/http" + + "apps/backend/internal/logic/utm" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ListUtmLinksHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := utm.NewListUtmLinksLogic(r.Context(), svcCtx) + data, err := l.ListUtmLinks() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/workspaces/add_workspace_member_handler.go b/apps/backend/internal/handler/workspaces/add_workspace_member_handler.go new file mode 100644 index 0000000..7424bef --- /dev/null +++ b/apps/backend/internal/handler/workspaces/add_workspace_member_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package workspaces + +import ( + "net/http" + + "apps/backend/internal/logic/workspaces" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func AddWorkspaceMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AddWorkspaceMemberReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := workspaces.NewAddWorkspaceMemberLogic(r.Context(), svcCtx) + data, err := l.AddWorkspaceMember(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/workspaces/approve_draft_review_handler.go b/apps/backend/internal/handler/workspaces/approve_draft_review_handler.go new file mode 100644 index 0000000..78e5cf7 --- /dev/null +++ b/apps/backend/internal/handler/workspaces/approve_draft_review_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package workspaces + +import ( + "net/http" + + "apps/backend/internal/logic/workspaces" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ApproveDraftReviewHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ReviewDecisionReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := workspaces.NewApproveDraftReviewLogic(r.Context(), svcCtx) + data, err := l.ApproveDraftReview(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/workspaces/archive_workspace_handler.go b/apps/backend/internal/handler/workspaces/archive_workspace_handler.go new file mode 100644 index 0000000..472d494 --- /dev/null +++ b/apps/backend/internal/handler/workspaces/archive_workspace_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package workspaces + +import ( + "net/http" + + "apps/backend/internal/logic/workspaces" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ArchiveWorkspaceHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.WorkspaceIdReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := workspaces.NewArchiveWorkspaceLogic(r.Context(), svcCtx) + data, err := l.ArchiveWorkspace(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/workspaces/create_workspace_handler.go b/apps/backend/internal/handler/workspaces/create_workspace_handler.go new file mode 100644 index 0000000..6348712 --- /dev/null +++ b/apps/backend/internal/handler/workspaces/create_workspace_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package workspaces + +import ( + "net/http" + + "apps/backend/internal/logic/workspaces" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func CreateWorkspaceHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CreateWorkspaceReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := workspaces.NewCreateWorkspaceLogic(r.Context(), svcCtx) + data, err := l.CreateWorkspace(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/workspaces/export_workspace_report_handler.go b/apps/backend/internal/handler/workspaces/export_workspace_report_handler.go new file mode 100644 index 0000000..9cebc30 --- /dev/null +++ b/apps/backend/internal/handler/workspaces/export_workspace_report_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package workspaces + +import ( + "net/http" + + "apps/backend/internal/logic/workspaces" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ExportWorkspaceReportHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ExportReportReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := workspaces.NewExportWorkspaceReportLogic(r.Context(), svcCtx) + data, err := l.ExportWorkspaceReport(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/workspaces/list_draft_reviews_handler.go b/apps/backend/internal/handler/workspaces/list_draft_reviews_handler.go new file mode 100644 index 0000000..3e60cad --- /dev/null +++ b/apps/backend/internal/handler/workspaces/list_draft_reviews_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package workspaces + +import ( + "net/http" + + "apps/backend/internal/logic/workspaces" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListDraftReviewsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ListDraftReviewsReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := workspaces.NewListDraftReviewsLogic(r.Context(), svcCtx) + data, err := l.ListDraftReviews(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/workspaces/list_workspace_members_handler.go b/apps/backend/internal/handler/workspaces/list_workspace_members_handler.go new file mode 100644 index 0000000..c490738 --- /dev/null +++ b/apps/backend/internal/handler/workspaces/list_workspace_members_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package workspaces + +import ( + "net/http" + + "apps/backend/internal/logic/workspaces" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListWorkspaceMembersHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ListWorkspaceMembersReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := workspaces.NewListWorkspaceMembersLogic(r.Context(), svcCtx) + data, err := l.ListWorkspaceMembers(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/workspaces/list_workspaces_handler.go b/apps/backend/internal/handler/workspaces/list_workspaces_handler.go new file mode 100644 index 0000000..d71d704 --- /dev/null +++ b/apps/backend/internal/handler/workspaces/list_workspaces_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package workspaces + +import ( + "net/http" + + "apps/backend/internal/logic/workspaces" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ListWorkspacesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := workspaces.NewListWorkspacesLogic(r.Context(), svcCtx) + data, err := l.ListWorkspaces() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/workspaces/reject_draft_review_handler.go b/apps/backend/internal/handler/workspaces/reject_draft_review_handler.go new file mode 100644 index 0000000..4b12881 --- /dev/null +++ b/apps/backend/internal/handler/workspaces/reject_draft_review_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package workspaces + +import ( + "net/http" + + "apps/backend/internal/logic/workspaces" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RejectDraftReviewHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ReviewDecisionReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := workspaces.NewRejectDraftReviewLogic(r.Context(), svcCtx) + data, err := l.RejectDraftReview(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/workspaces/remove_workspace_member_handler.go b/apps/backend/internal/handler/workspaces/remove_workspace_member_handler.go new file mode 100644 index 0000000..f8c2572 --- /dev/null +++ b/apps/backend/internal/handler/workspaces/remove_workspace_member_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package workspaces + +import ( + "net/http" + + "apps/backend/internal/logic/workspaces" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RemoveWorkspaceMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.RemoveWorkspaceMemberReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := workspaces.NewRemoveWorkspaceMemberLogic(r.Context(), svcCtx) + data, err := l.RemoveWorkspaceMember(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/workspaces/submit_draft_review_handler.go b/apps/backend/internal/handler/workspaces/submit_draft_review_handler.go new file mode 100644 index 0000000..5f874b5 --- /dev/null +++ b/apps/backend/internal/handler/workspaces/submit_draft_review_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package workspaces + +import ( + "net/http" + + "apps/backend/internal/logic/workspaces" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SubmitDraftReviewHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.SubmitDraftReviewReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := workspaces.NewSubmitDraftReviewLogic(r.Context(), svcCtx) + data, err := l.SubmitDraftReview(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/workspaces/switch_workspace_handler.go b/apps/backend/internal/handler/workspaces/switch_workspace_handler.go new file mode 100644 index 0000000..5f3a25a --- /dev/null +++ b/apps/backend/internal/handler/workspaces/switch_workspace_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package workspaces + +import ( + "net/http" + + "apps/backend/internal/logic/workspaces" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SwitchWorkspaceHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.SwitchWorkspaceReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := workspaces.NewSwitchWorkspaceLogic(r.Context(), svcCtx) + data, err := l.SwitchWorkspace(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/workspaces/update_workspace_branding_handler.go b/apps/backend/internal/handler/workspaces/update_workspace_branding_handler.go new file mode 100644 index 0000000..3e9ded0 --- /dev/null +++ b/apps/backend/internal/handler/workspaces/update_workspace_branding_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package workspaces + +import ( + "net/http" + + "apps/backend/internal/logic/workspaces" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func UpdateWorkspaceBrandingHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.WorkspaceBrandingReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := workspaces.NewUpdateWorkspaceBrandingLogic(r.Context(), svcCtx) + data, err := l.UpdateWorkspaceBranding(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/workspaces/update_workspace_handler.go b/apps/backend/internal/handler/workspaces/update_workspace_handler.go new file mode 100644 index 0000000..d66c664 --- /dev/null +++ b/apps/backend/internal/handler/workspaces/update_workspace_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package workspaces + +import ( + "net/http" + + "apps/backend/internal/logic/workspaces" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func UpdateWorkspaceHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UpdateWorkspaceReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := workspaces.NewUpdateWorkspaceLogic(r.Context(), svcCtx) + data, err := l.UpdateWorkspace(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/logic/benchmark/get_benchmark_logic.go b/apps/backend/internal/logic/benchmark/get_benchmark_logic.go new file mode 100644 index 0000000..9c7dd18 --- /dev/null +++ b/apps/backend/internal/logic/benchmark/get_benchmark_logic.go @@ -0,0 +1,66 @@ +package benchmark + +import ( + "context" + "fmt" + + "apps/backend/internal/middleware" + "apps/backend/internal/module/growth/usecase" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetBenchmarkLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetBenchmarkLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetBenchmarkLogic { + return &GetBenchmarkLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetBenchmarkLogic) GetBenchmark(req *types.BenchmarkReq) (*types.BenchmarkData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + niche := req.Niche + if niche == "" { + niche = "general" + } + var yourEng, yourViews float64 + // contribute + read from own posts if studio available + if l.svcCtx.Studio != nil { + posts, err := l.svcCtx.Studio.ListOwnPosts(l.ctx, uid, req.AccountId) + if err == nil && len(posts) > 0 { + var views, likes, replies int + n := 0 + for _, p := range posts { + if p == nil { + continue + } + n++ + views += p.ViewCount + likes += p.LikeCount + replies += p.ReplyCount + } + if n > 0 { + yourViews = float64(views) / float64(n) + if views > 0 { + yourEng = float64(likes+replies) / float64(views) + } + _ = l.svcCtx.Growth.ContributeBenchmark(l.ctx, uid, niche, yourEng, yourViews) + } + } + } + sample, medEng, medViews, avail, hint := l.svcCtx.Growth.GetBenchmark(l.ctx, niche, yourEng, yourViews) + return &types.BenchmarkData{ + Available: avail, SampleSize: sample, Niche: niche, + MedianEngRate: usecase.Round2(medEng), MedianViews: usecase.Round2(medViews), + YourEngRate: usecase.Round2(yourEng), YourViews: usecase.Round2(yourViews), + PercentileHint: hint, Message: hint, + }, nil +} diff --git a/apps/backend/internal/logic/checkups/generate_checkup_now_logic.go b/apps/backend/internal/logic/checkups/generate_checkup_now_logic.go new file mode 100644 index 0000000..a3a0e98 --- /dev/null +++ b/apps/backend/internal/logic/checkups/generate_checkup_now_logic.go @@ -0,0 +1,39 @@ +package checkups + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GenerateCheckupNowLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGenerateCheckupNowLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateCheckupNowLogic { + return &GenerateCheckupNowLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GenerateCheckupNowLogic) GenerateCheckupNow() (*types.WeeklyCheckupPublic, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + tz := "" + if m, err := l.svcCtx.Members.FindByUID(l.ctx, uid); err == nil && m != nil { + tz = m.Timezone + } + c, err := l.svcCtx.Growth.GenerateCheckup(l.ctx, uid, tz, true) + if err != nil { + return nil, err + } + return growthmap.Checkup(c), nil +} diff --git a/apps/backend/internal/logic/checkups/get_checkup_logic.go b/apps/backend/internal/logic/checkups/get_checkup_logic.go new file mode 100644 index 0000000..1030ae7 --- /dev/null +++ b/apps/backend/internal/logic/checkups/get_checkup_logic.go @@ -0,0 +1,35 @@ +package checkups + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetCheckupLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetCheckupLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCheckupLogic { + return &GetCheckupLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetCheckupLogic) GetCheckup(req *types.GetCheckupReq) (*types.WeeklyCheckupPublic, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + c, err := l.svcCtx.Growth.GetCheckup(l.ctx, uid, req.Id) + if err != nil { + return nil, err + } + return growthmap.Checkup(c), nil +} diff --git a/apps/backend/internal/logic/checkups/get_latest_checkup_logic.go b/apps/backend/internal/logic/checkups/get_latest_checkup_logic.go new file mode 100644 index 0000000..d346888 --- /dev/null +++ b/apps/backend/internal/logic/checkups/get_latest_checkup_logic.go @@ -0,0 +1,45 @@ +package checkups + +import ( + "context" + "errors" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + growthDomain "apps/backend/internal/module/growth/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetLatestCheckupLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetLatestCheckupLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetLatestCheckupLogic { + return &GetLatestCheckupLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetLatestCheckupLogic) GetLatestCheckup() (*types.WeeklyCheckupPublic, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + c, err := l.svcCtx.Growth.LatestCheckup(l.ctx, uid) + if err != nil { + if errors.Is(err, growthDomain.ErrNotFound) { + // auto-generate first + c, err = l.svcCtx.Growth.GenerateCheckup(l.ctx, uid, "", false) + if err != nil { + return nil, err + } + return growthmap.Checkup(c), nil + } + return nil, err + } + return growthmap.Checkup(c), nil +} diff --git a/apps/backend/internal/logic/checkups/list_checkups_logic.go b/apps/backend/internal/logic/checkups/list_checkups_logic.go new file mode 100644 index 0000000..50809c2 --- /dev/null +++ b/apps/backend/internal/logic/checkups/list_checkups_logic.go @@ -0,0 +1,38 @@ +package checkups + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListCheckupsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListCheckupsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCheckupsLogic { + return &ListCheckupsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListCheckupsLogic) ListCheckups(req *types.ListCheckupsReq) (*types.CheckupListData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + list, total, err := l.svcCtx.Growth.ListCheckups(l.ctx, uid, req.Page, req.PageSize) + if err != nil { + return nil, err + } + return &types.CheckupListData{ + List: growthmap.CheckupList(list), + Pagination: growthmap.Pagination(req.Page, req.PageSize, total), + }, nil +} diff --git a/apps/backend/internal/logic/growthmap/map.go b/apps/backend/internal/logic/growthmap/map.go new file mode 100644 index 0000000..e492fe4 --- /dev/null +++ b/apps/backend/internal/logic/growthmap/map.go @@ -0,0 +1,166 @@ +package growthmap + +import ( + "apps/backend/internal/module/growth/domain" + "apps/backend/internal/types" +) + +func Outcome(e *domain.OutcomeEvent) *types.OutcomeEventPublic { + if e == nil { + return nil + } + return &types.OutcomeEventPublic{ + Id: e.ID, + SourceType: e.SourceType, + SourceId: e.SourceID, + ThreadsAccountId: e.ThreadsAccountID, + Kind: e.Kind, + Confidence: e.Confidence, + Status: e.Status, + ReplyCountDelta: e.ReplyCountDelta, + LikeCountDelta: e.LikeCountDelta, + FollowersDelta: e.FollowersDelta, + ConversionAmount: e.ConversionAmount, + ConversionNote: e.ConversionNote, + ConversionCurrency: e.ConversionCurrency, + WindowEndsAt: e.WindowEndsAt, + SentAt: e.SentAt, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +func OutcomeList(list []*domain.OutcomeEvent) []types.OutcomeEventPublic { + out := make([]types.OutcomeEventPublic, 0, len(list)) + for _, e := range list { + if p := Outcome(e); p != nil { + out = append(out, *p) + } + } + return out +} + +func Summary(s *domain.OutcomeSummary) *types.OutcomeSummaryData { + if s == nil { + return nil + } + return &types.OutcomeSummaryData{ + From: s.From, + To: s.To, + Reach: s.Reach, + Conversations: s.Conversations, + FollowsPossible: s.FollowsPossible, + FollowsConfirmed: s.FollowsConfirmed, + Conversions: s.Conversions, + ConversionAmount: s.ConversionAmount, + } +} + +func Checkup(c *domain.WeeklyCheckup) *types.WeeklyCheckupPublic { + if c == nil { + return nil + } + findings := make([]types.CheckupFindingPublic, 0, len(c.Findings)) + for _, f := range c.Findings { + findings = append(findings, types.CheckupFindingPublic{Claim: f.Claim, Evidence: f.Evidence}) + } + actions := make([]types.CheckupActionPublic, 0, len(c.Actions)) + for _, a := range c.Actions { + actions = append(actions, types.CheckupActionPublic{Title: a.Title, Reason: a.Reason, Deeplink: a.Deeplink}) + } + return &types.WeeklyCheckupPublic{ + Id: c.ID, WeekKey: c.WeekKey, Status: c.Status, Summary: c.Summary, + Findings: findings, Actions: actions, Error: c.Error, + CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, + } +} + +func CheckupList(list []*domain.WeeklyCheckup) []types.WeeklyCheckupPublic { + out := make([]types.WeeklyCheckupPublic, 0, len(list)) + for _, c := range list { + if p := Checkup(c); p != nil { + out = append(out, *p) + } + } + return out +} + +func Health(h *domain.AccountHealth) *types.AccountHealthPublic { + if h == nil { + return nil + } + return &types.AccountHealthPublic{ + ThreadsAccountId: h.ThreadsAccountID, + Score: h.Score, + Level: h.Level, + Factors: h.Factors, + Advice: h.Advice, + ComputedAt: h.ComputedAt, + } +} + +func HealthList(list []*domain.AccountHealth) []types.AccountHealthPublic { + out := make([]types.AccountHealthPublic, 0, len(list)) + for _, h := range list { + if p := Health(h); p != nil { + out = append(out, *p) + } + } + return out +} + +func Workspace(w *domain.Workspace) *types.WorkspacePublic { + if w == nil { + return nil + } + return &types.WorkspacePublic{ + Id: w.ID, Name: w.Name, ReviewRequired: w.ReviewRequired, + IsDefault: w.IsDefault, Archived: w.Archived, + FooterText: w.FooterText, HidePoweredBy: w.HidePoweredBy, BrandName: w.BrandName, + CreatedAt: w.CreatedAt, UpdatedAt: w.UpdatedAt, + } +} + +func WorkspaceList(list []*domain.Workspace) []types.WorkspacePublic { + out := make([]types.WorkspacePublic, 0, len(list)) + for _, w := range list { + if p := Workspace(w); p != nil { + out = append(out, *p) + } + } + return out +} + +func Review(r *domain.DraftReview) *types.DraftReviewPublic { + if r == nil { + return nil + } + return &types.DraftReviewPublic{ + Id: r.ID, RefType: r.RefType, RefId: r.RefID, WorkspaceId: r.WorkspaceID, + Status: r.Status, Reason: r.Reason, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + } +} + +func ReviewList(list []*domain.DraftReview) []types.DraftReviewPublic { + out := make([]types.DraftReviewPublic, 0, len(list)) + for _, r := range list { + if p := Review(r); p != nil { + out = append(out, *p) + } + } + return out +} + +func Pagination(page, pageSize int, total int64) types.Pagination { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + tp := int(total) / pageSize + if int(total)%pageSize != 0 { + tp++ + } + return types.Pagination{Page: page, PageSize: pageSize, Total: total, TotalPages: tp} +} diff --git a/apps/backend/internal/logic/health/get_account_health_logic.go b/apps/backend/internal/logic/health/get_account_health_logic.go new file mode 100644 index 0000000..a1b6e3d --- /dev/null +++ b/apps/backend/internal/logic/health/get_account_health_logic.go @@ -0,0 +1,35 @@ +package health + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetAccountHealthLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetAccountHealthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetAccountHealthLogic { + return &GetAccountHealthLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetAccountHealthLogic) GetAccountHealth(req *types.GetAccountHealthReq) (*types.AccountHealthPublic, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + h, err := l.svcCtx.Growth.GetHealth(l.ctx, uid, req.AccountId) + if err != nil { + return nil, err + } + return growthmap.Health(h), nil +} diff --git a/apps/backend/internal/logic/health/list_account_health_logic.go b/apps/backend/internal/logic/health/list_account_health_logic.go new file mode 100644 index 0000000..54a03a5 --- /dev/null +++ b/apps/backend/internal/logic/health/list_account_health_logic.go @@ -0,0 +1,115 @@ +package health + +import ( + "context" + "fmt" + "time" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + growthUC "apps/backend/internal/module/growth/usecase" + studioDomain "apps/backend/internal/module/studio/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListAccountHealthLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListAccountHealthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListAccountHealthLogic { + return &ListAccountHealthLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListAccountHealthLogic) ListAccountHealth() (*types.AccountHealthListData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + accs, err := l.svcCtx.Threads.List(l.ctx, uid) + if err != nil { + return nil, err + } + // gather outbox stats per account + type stats struct { + sends24h, fails7d, total7d, intervalViol int + } + byAcc := map[string]*stats{} + now := time.Now().UTC() + dayAgo := now.Add(-24 * time.Hour).UnixNano() + weekAgo := now.Add(-7 * 24 * time.Hour).UnixNano() + if l.svcCtx.Studio != nil { + if boxes, oerr := l.svcCtx.Studio.ListOutbox(l.ctx, uid); oerr == nil { + for _, b := range boxes { + if b == nil { + continue + } + var prevAt int64 + for _, st := range b.Steps { + s := byAcc[st.AccountID] + if s == nil { + s = &stats{} + byAcc[st.AccountID] = s + } + ts := st.PublishedAt + if ts == 0 { + ts = st.ScheduledAt + } + if st.Status == studioDomain.StepPublished || st.Status == studioDomain.StepFailed || st.Status == studioDomain.StepPublishing { + if ts >= dayAgo { + s.sends24h++ + } + if ts >= weekAgo { + s.total7d++ + if st.Status == studioDomain.StepFailed { + s.fails7d++ + } + } + } + if prevAt > 0 && st.ScheduledAt > 0 { + gap := st.ScheduledAt - prevAt + // less than 2 minutes between steps → interval violation + if gap > 0 && gap < int64(2*time.Minute) { + s.intervalViol++ + } + } + if st.ScheduledAt > 0 { + prevAt = st.ScheduledAt + } + } + } + } + } + var list []*types.AccountHealthPublic + for _, a := range accs { + if a == nil { + continue + } + sig := growthUC.HealthSignals{} + if s := byAcc[a.ID]; s != nil { + sig.Sends24h = s.sends24h + sig.IntervalViolations = s.intervalViol + if s.total7d > 0 { + sig.FailRate7d = float64(s.fails7d) / float64(s.total7d) + } + } + sig.TokenBad = !a.IsUsable || a.Connection == "error" || a.Connection == "disconnected" + h, herr := l.svcCtx.Growth.RecomputeHealth(l.ctx, uid, a.ID, sig) + if herr != nil { + continue + } + list = append(list, growthmap.Health(h)) + } + // flatten for response type + out := make([]types.AccountHealthPublic, 0, len(list)) + for _, h := range list { + if h != nil { + out = append(out, *h) + } + } + return &types.AccountHealthListData{List: out}, nil +} diff --git a/apps/backend/internal/logic/insightsapi/get_insights_summary_logic.go b/apps/backend/internal/logic/insightsapi/get_insights_summary_logic.go new file mode 100644 index 0000000..5c295a3 --- /dev/null +++ b/apps/backend/internal/logic/insightsapi/get_insights_summary_logic.go @@ -0,0 +1,123 @@ +package insightsapi + +import ( + "context" + "fmt" + "sort" + "time" + + "apps/backend/internal/middleware" + "apps/backend/internal/module/growth/usecase" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetInsightsSummaryLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetInsightsSummaryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetInsightsSummaryLogic { + return &GetInsightsSummaryLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetInsightsSummaryLogic) GetInsightsSummary(req *types.InsightsSummaryReq) (*types.InsightsSummaryData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + if l.svcCtx.Studio == nil { + return &types.InsightsSummaryData{Conclusion: "尚無創作資料", Suggestions: []string{"先同步已發貼文"}}, nil + } + posts, err := l.svcCtx.Studio.ListOwnPosts(l.ctx, uid, req.AccountId) + if err != nil { + return nil, err + } + months := req.Months + if months <= 0 { + months = 3 + } + cutoff := time.Now().UTC().AddDate(0, -months, 0).UnixNano() + type agg struct{ posts, views, likes, replies int } + byMonth := map[string]*agg{} + var totalV, totalL, totalR, n int + var top []types.InsightsPostPublic + for _, p := range posts { + if p == nil { + continue + } + ts := p.PublishedAt + if ts > 0 && ts < cutoff { + continue + } + n++ + totalV += p.ViewCount + totalL += p.LikeCount + totalR += p.ReplyCount + mk := time.Unix(0, ts).UTC().Format("2006-01") + if ts == 0 { + mk = time.Now().UTC().Format("2006-01") + } + a := byMonth[mk] + if a == nil { + a = &agg{} + byMonth[mk] = a + } + a.posts++ + a.views += p.ViewCount + a.likes += p.LikeCount + a.replies += p.ReplyCount + eng := 0.0 + if p.ViewCount > 0 { + eng = float64(p.LikeCount+p.ReplyCount) / float64(p.ViewCount) + } + prev := p.Text + if len([]rune(prev)) > 80 { + prev = string([]rune(prev)[:80]) + "…" + } + top = append(top, types.InsightsPostPublic{ + Id: p.ID, TextPreview: prev, PublishedAt: ts, + ViewCount: p.ViewCount, LikeCount: p.LikeCount, ReplyCount: p.ReplyCount, EngRate: usecase.Round2(eng), + }) + } + sort.Slice(top, func(i, j int) bool { return top[i].EngRate > top[j].EngRate }) + if len(top) > 5 { + top = top[:5] + } + avgEng := 0.0 + if totalV > 0 { + avgEng = float64(totalL+totalR) / float64(totalV) + } + var buckets []types.InsightsMonthBucket + for mk, a := range byMonth { + e := 0.0 + if a.views > 0 { + e = float64(a.likes+a.replies) / float64(a.views) + } + buckets = append(buckets, types.InsightsMonthBucket{ + MonthKey: mk, Posts: a.posts, Views: a.views, Likes: a.likes, Replies: a.replies, AvgEngRate: usecase.Round2(e), + }) + } + sort.Slice(buckets, func(i, j int) bool { return buckets[i].MonthKey < buckets[j].MonthKey }) + conclusion := fmt.Sprintf("近 %d 個月同步貼文 %d 則,平均互動率 %.2f%%。", months, n, avgEng*100) + sugs := []string{"維持高互動題材節奏", "把表現佳貼文結構拿去仿寫"} + if n == 0 { + conclusion = "尚無足夠已同步貼文" + sugs = []string{"到創作 → 我的貼文按同步", "連好 Threads 帳號後再看成效"} + } else if avgEng < 0.02 { + sugs = append([]string{"互動率偏低:可加強開頭鉤子與提問 CTA"}, sugs...) + } + // also feed benchmark + if n > 0 { + _ = l.svcCtx.Growth.ContributeBenchmark(l.ctx, uid, "general", avgEng, float64(totalV)/float64(n)) + } + return &types.InsightsSummaryData{ + AccountId: req.AccountId, PostCount: n, TotalViews: totalV, TotalLikes: totalL, TotalReplies: totalR, + AvgEngRate: usecase.Round2(avgEng), TopPosts: top, MonthBuckets: buckets, + Conclusion: conclusion, Suggestions: sugs, + }, nil +} + diff --git a/apps/backend/internal/logic/invite/get_my_invite_network_logic.go b/apps/backend/internal/logic/invite/get_my_invite_network_logic.go index 13be63d..abc9ea6 100644 --- a/apps/backend/internal/logic/invite/get_my_invite_network_logic.go +++ b/apps/backend/internal/logic/invite/get_my_invite_network_logic.go @@ -30,5 +30,33 @@ func (l *GetMyInviteNetworkLogic) GetMyInviteNetwork() (resp *types.MyInviteNetw if err != nil { return nil, err } - return types.MyInviteNetworkFromUC(net), nil + out := types.MyInviteNetworkFromUC(net) + if l.svcCtx.Growth != nil && out != nil { + planID := "free" + if l.svcCtx.Usage != nil { + if prefs, perr := l.svcCtx.Usage.GetPrefs(l.ctx, uid); perr == nil && prefs != nil { + planID = prefs.PlanID + } + } + total, month, cap, recent, rerr := l.svcCtx.Growth.InviteRewardsSummary(l.ctx, uid, planID) + if rerr == nil { + sum := &types.InviteRewardsSummary{ + TotalPoints: total, + MonthPoints: month, + MonthCap: cap, + Recent: make([]types.InviteRewardPublic, 0, len(recent)), + } + for _, r := range recent { + if r == nil { + continue + } + sum.Recent = append(sum.Recent, types.InviteRewardPublic{ + Id: r.ID, InviteeUid: r.InviteeUID, PlanId: r.PlanID, + Points: r.Points, Status: r.Status, CreatedAt: r.CreatedAt, + }) + } + out.Rewards = sum + } + } + return out, nil } diff --git a/apps/backend/internal/logic/outcomes/delete_conversion_logic.go b/apps/backend/internal/logic/outcomes/delete_conversion_logic.go new file mode 100644 index 0000000..d0ac20f --- /dev/null +++ b/apps/backend/internal/logic/outcomes/delete_conversion_logic.go @@ -0,0 +1,33 @@ +package outcomes + +import ( + "context" + "fmt" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type DeleteConversionLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeleteConversionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteConversionLogic { + return &DeleteConversionLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *DeleteConversionLogic) DeleteConversion(req *types.DeleteConversionReq) (*types.OkData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + if err := l.svcCtx.Growth.DeleteConversion(l.ctx, uid, req.Id); err != nil { + return nil, err + } + return &types.OkData{Ok: true}, nil +} diff --git a/apps/backend/internal/logic/outcomes/get_outcome_logic.go b/apps/backend/internal/logic/outcomes/get_outcome_logic.go new file mode 100644 index 0000000..43704e8 --- /dev/null +++ b/apps/backend/internal/logic/outcomes/get_outcome_logic.go @@ -0,0 +1,35 @@ +package outcomes + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetOutcomeLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetOutcomeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetOutcomeLogic { + return &GetOutcomeLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetOutcomeLogic) GetOutcome(req *types.GetOutcomeReq) (*types.OutcomeEventPublic, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + e, err := l.svcCtx.Growth.GetOutcome(l.ctx, uid, req.Id) + if err != nil { + return nil, err + } + return growthmap.Outcome(e), nil +} diff --git a/apps/backend/internal/logic/outcomes/get_outcome_summary_logic.go b/apps/backend/internal/logic/outcomes/get_outcome_summary_logic.go new file mode 100644 index 0000000..218a8a6 --- /dev/null +++ b/apps/backend/internal/logic/outcomes/get_outcome_summary_logic.go @@ -0,0 +1,45 @@ +package outcomes + +import ( + "context" + "fmt" + "time" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetOutcomeSummaryLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetOutcomeSummaryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetOutcomeSummaryLogic { + return &GetOutcomeSummaryLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetOutcomeSummaryLogic) GetOutcomeSummary(req *types.OutcomeSummaryReq) (*types.OutcomeSummaryData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + from, to := req.From, req.To + if from == 0 && to == 0 { + to = time.Now().UTC().UnixNano() + if req.Range == "month" { + from = to - int64(30*24*time.Hour) + } else { + from = to - int64(7*24*time.Hour) + } + } + sum, err := l.svcCtx.Growth.Summary(l.ctx, uid, from, to) + if err != nil { + return nil, err + } + return growthmap.Summary(sum), nil +} diff --git a/apps/backend/internal/logic/outcomes/list_outcomes_logic.go b/apps/backend/internal/logic/outcomes/list_outcomes_logic.go new file mode 100644 index 0000000..edfe2ba --- /dev/null +++ b/apps/backend/internal/logic/outcomes/list_outcomes_logic.go @@ -0,0 +1,42 @@ +package outcomes + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + growthDomain "apps/backend/internal/module/growth/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListOutcomesLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListOutcomesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListOutcomesLogic { + return &ListOutcomesLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListOutcomesLogic) ListOutcomes(req *types.ListOutcomesReq) (*types.OutcomeListData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + list, total, err := l.svcCtx.Growth.ListOutcomes(l.ctx, uid, growthDomain.OutcomeListFilter{ + Kind: req.Kind, Confidence: req.Confidence, SourceType: req.SourceType, + From: req.From, To: req.To, Page: req.Page, PageSize: req.PageSize, + }) + if err != nil { + return nil, err + } + return &types.OutcomeListData{ + List: growthmap.OutcomeList(list), + Pagination: growthmap.Pagination(req.Page, req.PageSize, total), + }, nil +} diff --git a/apps/backend/internal/logic/outcomes/report_conversion_logic.go b/apps/backend/internal/logic/outcomes/report_conversion_logic.go new file mode 100644 index 0000000..5366523 --- /dev/null +++ b/apps/backend/internal/logic/outcomes/report_conversion_logic.go @@ -0,0 +1,35 @@ +package outcomes + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ReportConversionLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewReportConversionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ReportConversionLogic { + return &ReportConversionLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ReportConversionLogic) ReportConversion(req *types.ReportConversionReq) (*types.OutcomeEventPublic, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + e, err := l.svcCtx.Growth.ReportConversion(l.ctx, uid, req.Id, req.Amount, req.Note, req.Currency) + if err != nil { + return nil, err + } + return growthmap.Outcome(e), nil +} diff --git a/apps/backend/internal/logic/outcomes/update_conversion_logic.go b/apps/backend/internal/logic/outcomes/update_conversion_logic.go new file mode 100644 index 0000000..d3031b0 --- /dev/null +++ b/apps/backend/internal/logic/outcomes/update_conversion_logic.go @@ -0,0 +1,35 @@ +package outcomes + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpdateConversionLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateConversionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateConversionLogic { + return &UpdateConversionLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *UpdateConversionLogic) UpdateConversion(req *types.UpdateConversionReq) (*types.OutcomeEventPublic, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + e, err := l.svcCtx.Growth.UpdateConversion(l.ctx, uid, req.Id, req.Amount, req.Note, req.Currency) + if err != nil { + return nil, err + } + return growthmap.Outcome(e), nil +} diff --git a/apps/backend/internal/logic/playbooks/get_playbook_logic.go b/apps/backend/internal/logic/playbooks/get_playbook_logic.go new file mode 100644 index 0000000..c5754e7 --- /dev/null +++ b/apps/backend/internal/logic/playbooks/get_playbook_logic.go @@ -0,0 +1,35 @@ +package playbooks + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/module/growth/usecase" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetPlaybookLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPlaybookLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPlaybookLogic { + return &GetPlaybookLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetPlaybookLogic) GetPlaybook(req *types.PlaybookIdPath) (*types.PlaybookPublic, error) { + uid, _ := middleware.UIDFrom(l.ctx) + p, err := l.svcCtx.Growth.GetPlaybook(l.ctx, req.Id) + if err != nil { + return nil, err + } + return &types.PlaybookPublic{ + Id: p.ID, Kind: p.Kind, Title: p.Title, Niche: p.Niche, Body: p.Body, + Anonymous: p.Anonymous, AuthorLabel: usecase.PlaybookAuthorLabel(p, uid), + ImportCount: p.ImportCount, CreatedAt: p.CreatedAt, Mine: p.OwnerUID == uid, + }, nil +} diff --git a/apps/backend/internal/logic/playbooks/import_playbook_logic.go b/apps/backend/internal/logic/playbooks/import_playbook_logic.go new file mode 100644 index 0000000..1795912 --- /dev/null +++ b/apps/backend/internal/logic/playbooks/import_playbook_logic.go @@ -0,0 +1,34 @@ +package playbooks + +import ( + "context" + "fmt" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ImportPlaybookLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewImportPlaybookLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ImportPlaybookLogic { + return &ImportPlaybookLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ImportPlaybookLogic) ImportPlaybook(req *types.PlaybookIdPath) (*types.ImportPlaybookData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + p, err := l.svcCtx.Growth.ImportPlaybook(l.ctx, uid, req.Id) + if err != nil { + return nil, err + } + return &types.ImportPlaybookData{Id: p.ID, Copied: true, Message: "已複製到我的 playbook"}, nil +} diff --git a/apps/backend/internal/logic/playbooks/list_playbooks_logic.go b/apps/backend/internal/logic/playbooks/list_playbooks_logic.go new file mode 100644 index 0000000..eff690d --- /dev/null +++ b/apps/backend/internal/logic/playbooks/list_playbooks_logic.go @@ -0,0 +1,44 @@ +package playbooks + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + "apps/backend/internal/module/growth/usecase" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListPlaybooksLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListPlaybooksLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPlaybooksLogic { + return &ListPlaybooksLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListPlaybooksLogic) ListPlaybooks(req *types.ListPlaybooksReq) (*types.PlaybookListData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + list, total, err := l.svcCtx.Growth.ListPlaybooks(l.ctx, uid, req.Kind, req.Niche, req.Mine, req.Page, req.PageSize) + if err != nil { + return nil, err + } + out := make([]types.PlaybookPublic, 0, len(list)) + for _, p := range list { + out = append(out, types.PlaybookPublic{ + Id: p.ID, Kind: p.Kind, Title: p.Title, Niche: p.Niche, Body: p.Body, + Anonymous: p.Anonymous, AuthorLabel: usecase.PlaybookAuthorLabel(p, uid), + ImportCount: p.ImportCount, CreatedAt: p.CreatedAt, Mine: p.OwnerUID == uid, + }) + } + return &types.PlaybookListData{List: out, Pagination: growthmap.Pagination(req.Page, req.PageSize, total)}, nil +} diff --git a/apps/backend/internal/logic/playbooks/publish_playbook_logic.go b/apps/backend/internal/logic/playbooks/publish_playbook_logic.go new file mode 100644 index 0000000..dfa64bb --- /dev/null +++ b/apps/backend/internal/logic/playbooks/publish_playbook_logic.go @@ -0,0 +1,39 @@ +package playbooks + +import ( + "context" + "fmt" + + "apps/backend/internal/middleware" + "apps/backend/internal/module/growth/usecase" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type PublishPlaybookLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPublishPlaybookLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublishPlaybookLogic { + return &PublishPlaybookLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *PublishPlaybookLogic) PublishPlaybook(req *types.PublishPlaybookReq) (*types.PlaybookPublic, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + p, err := l.svcCtx.Growth.PublishPlaybook(l.ctx, uid, req.Kind, req.Title, req.Niche, req.Body, req.Anonymous) + if err != nil { + return nil, err + } + return &types.PlaybookPublic{ + Id: p.ID, Kind: p.Kind, Title: p.Title, Niche: p.Niche, Body: p.Body, + Anonymous: p.Anonymous, AuthorLabel: usecase.PlaybookAuthorLabel(p, uid), + ImportCount: p.ImportCount, CreatedAt: p.CreatedAt, Mine: true, + }, nil +} diff --git a/apps/backend/internal/logic/playbooks/remove_playbook_logic.go b/apps/backend/internal/logic/playbooks/remove_playbook_logic.go new file mode 100644 index 0000000..a52cd98 --- /dev/null +++ b/apps/backend/internal/logic/playbooks/remove_playbook_logic.go @@ -0,0 +1,33 @@ +package playbooks + +import ( + "context" + "fmt" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RemovePlaybookLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRemovePlaybookLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemovePlaybookLogic { + return &RemovePlaybookLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RemovePlaybookLogic) RemovePlaybook(req *types.PlaybookIdPath) (*types.OkData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + if err := l.svcCtx.Growth.RemovePlaybook(l.ctx, uid, req.Id); err != nil { + return nil, err + } + return &types.OkData{Ok: true}, nil +} diff --git a/apps/backend/internal/logic/plays/submit_play_logic.go b/apps/backend/internal/logic/plays/submit_play_logic.go index 390a430..bf7d745 100644 --- a/apps/backend/internal/logic/plays/submit_play_logic.go +++ b/apps/backend/internal/logic/plays/submit_play_logic.go @@ -29,6 +29,22 @@ func (l *SubmitPlayLogic) SubmitPlay(req *types.PlayIdPath) (*types.OutboxPublic if !ok { return nil, response.Biz(401, 401001, "missing authorization") } + if l.svcCtx.Growth != nil { + if play, perr := l.svcCtx.Studio.GetPlay(l.ctx, uid, req.Id); perr == nil && play != nil { + accID := play.LeadAccountID + if accID == "" && len(play.Steps) > 0 { + accID = play.Steps[0].AccountID + } + if accID != "" { + if _, herr := l.svcCtx.Growth.EnsureAutoSendAllowed(l.ctx, uid, accID); herr != nil { + return nil, herr + } + } + if err := l.svcCtx.Growth.EnsureReviewAllowsAutoSend(l.ctx, uid, "", "play", req.Id); err != nil { + return nil, err + } + } + } b, err := l.svcCtx.Studio.SubmitPlay(l.ctx, uid, req.Id) if err != nil { return nil, err diff --git a/apps/backend/internal/logic/publictools/public_pain_keywords_logic.go b/apps/backend/internal/logic/publictools/public_pain_keywords_logic.go new file mode 100644 index 0000000..d4bd5c0 --- /dev/null +++ b/apps/backend/internal/logic/publictools/public_pain_keywords_logic.go @@ -0,0 +1,29 @@ +package publictools + +import ( + "context" + + "apps/backend/internal/module/growth/usecase" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type PublicPainKeywordsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPublicPainKeywordsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublicPainKeywordsLogic { + return &PublicPainKeywordsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *PublicPainKeywordsLogic) PublicPainKeywords(req *types.PainKeywordsReq) (*types.PainKeywordsData, error) { + kw, pains, scan, summary := usecase.PainKeywords(req.ProductBrief, req.Audience) + return &types.PainKeywordsData{ + Keywords: kw, Pains: pains, ScanTerms: scan, Summary: summary, + SignupHint: "把掃描詞丟進海巡 brief,登入後一鍵找痛點貼文。", + }, nil +} diff --git a/apps/backend/internal/logic/publictools/public_style_quiz_logic.go b/apps/backend/internal/logic/publictools/public_style_quiz_logic.go new file mode 100644 index 0000000..d8b88f3 --- /dev/null +++ b/apps/backend/internal/logic/publictools/public_style_quiz_logic.go @@ -0,0 +1,29 @@ +package publictools + +import ( + "context" + + "apps/backend/internal/module/growth/usecase" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type PublicStyleQuizLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPublicStyleQuizLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublicStyleQuizLogic { + return &PublicStyleQuizLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *PublicStyleQuizLogic) PublicStyleQuiz(req *types.StyleQuizReq) (*types.StyleQuizData, error) { + tone, rhythm, hooks, avoid, summary, tags := usecase.StyleQuiz(req.Samples) + return &types.StyleQuizData{ + Tone: tone, Rhythm: rhythm, Hooks: hooks, Avoid: avoid, Summary: summary, Tags: tags, + SignupHint: "想用人設語紋自動產文?登入巡樓試試。", + }, nil +} diff --git a/apps/backend/internal/logic/publicutm/utm_redirect_logic.go b/apps/backend/internal/logic/publicutm/utm_redirect_logic.go new file mode 100644 index 0000000..cae5bc9 --- /dev/null +++ b/apps/backend/internal/logic/publicutm/utm_redirect_logic.go @@ -0,0 +1,41 @@ +package publicutm + +import ( + "context" + "net/http" + + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UtmRedirectLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext + w http.ResponseWriter + r *http.Request +} + +func NewUtmRedirectLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UtmRedirectLogic { + return &UtmRedirectLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +// SetHTTP allows handler to pass ResponseWriter for 302 +func (l *UtmRedirectLogic) SetHTTP(w http.ResponseWriter, r *http.Request) { + l.w, l.r = w, r +} + +func (l *UtmRedirectLogic) UtmRedirect(req *types.UtmRedirectReq) (*types.OkData, error) { + dest, err := l.svcCtx.Growth.TrackUtmClick(l.ctx, req.Code) + if err != nil { + return nil, err + } + if l.w != nil { + http.Redirect(l.w, l.r, dest, http.StatusFound) + return nil, response.Biz(302, 302000, "redirect") + } + return &types.OkData{Ok: true, Message: dest}, nil +} diff --git a/apps/backend/internal/logic/scout/send_outreach_logic.go b/apps/backend/internal/logic/scout/send_outreach_logic.go index 9d8402c..09a396f 100644 --- a/apps/backend/internal/logic/scout/send_outreach_logic.go +++ b/apps/backend/internal/logic/scout/send_outreach_logic.go @@ -30,6 +30,14 @@ func (l *SendOutreachLogic) SendOutreach(req *types.ScoutSendPathReq) (*types.Sc return nil, response.Biz(401, 401001, "missing authorization") } + if l.svcCtx.Growth != nil && req.AccountId != "" { + if _, herr := l.svcCtx.Growth.EnsureAutoSendAllowed(l.ctx, uid, req.AccountId); herr != nil { + return nil, herr + } + if err := l.svcCtx.Growth.EnsureReviewAllowsAutoSend(l.ctx, uid, "", "scout_post", req.Id); err != nil { + return nil, err + } + } p, err := l.svcCtx.Scout.SendOutreach(l.ctx, uid, req.Id, req.Text, req.AccountId) if err != nil { return nil, err diff --git a/apps/backend/internal/logic/usage/purchase_plan_logic.go b/apps/backend/internal/logic/usage/purchase_plan_logic.go index 510cfc1..7525b95 100644 --- a/apps/backend/internal/logic/usage/purchase_plan_logic.go +++ b/apps/backend/internal/logic/usage/purchase_plan_logic.go @@ -33,5 +33,36 @@ func (l *PurchasePlanLogic) PurchasePlan(req *types.UsagePurchaseReq) (*types.Us if err != nil { return nil, err } + // growth-loop: first paid plan → invite reward (single-level) + if l.svcCtx.Growth != nil { + if m, merr := l.svcCtx.Members.FindByUID(l.ctx, uid); merr == nil && m != nil && m.ParentUID != nil && *m.ParentUID > 0 { + parentUID := *m.ParentUID + inviterPlan := "free" + if prefs, perr := l.svcCtx.Usage.GetPrefs(l.ctx, parentUID); perr == nil && prefs != nil { + inviterPlan = prefs.PlanID + } + l.svcCtx.Growth.PlanQuota = func(id string) int { + if id == "@inviter" { + switch inviterPlan { + case "starter": + return 600 + case "pro": + return 2000 + default: + return 120 + } + } + switch id { + case "starter": + return 600 + case "pro": + return 2000 + default: + return 120 + } + } + _, _ = l.svcCtx.Growth.OnFirstPaidPlan(l.ctx, uid, parentUID, p.PlanID) + } + } return &types.UsagePurchasePublic{Id: p.ID, PlanId: p.PlanID, MockRef: p.MockRef, CreatedAt: p.CreatedAt}, nil } diff --git a/apps/backend/internal/logic/utm/create_utm_link_logic.go b/apps/backend/internal/logic/utm/create_utm_link_logic.go new file mode 100644 index 0000000..0fe27f5 --- /dev/null +++ b/apps/backend/internal/logic/utm/create_utm_link_logic.go @@ -0,0 +1,38 @@ +package utm + +import ( + "context" + "fmt" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type CreateUtmLinkLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateUtmLinkLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateUtmLinkLogic { + return &CreateUtmLinkLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *CreateUtmLinkLogic) CreateUtmLink(req *types.CreateUtmLinkReq) (*types.UtmLinkPublic, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + u, err := l.svcCtx.Growth.CreateUtmLink(l.ctx, uid, req.DestinationUrl, req.OutcomeId, req.Label) + if err != nil { + return nil, err + } + return &types.UtmLinkPublic{ + Id: u.ID, Code: u.Code, TrackPath: "/api/v1/public/u/" + u.Code, + DestinationUrl: u.DestinationURL, OutcomeId: u.OutcomeID, Label: u.Label, + Clicks: u.Clicks, CreatedAt: u.CreatedAt, + }, nil +} diff --git a/apps/backend/internal/logic/utm/list_utm_links_logic.go b/apps/backend/internal/logic/utm/list_utm_links_logic.go new file mode 100644 index 0000000..9f07b4e --- /dev/null +++ b/apps/backend/internal/logic/utm/list_utm_links_logic.go @@ -0,0 +1,42 @@ +package utm + +import ( + "context" + "fmt" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListUtmLinksLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListUtmLinksLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListUtmLinksLogic { + return &ListUtmLinksLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListUtmLinksLogic) ListUtmLinks() (*types.UtmLinkListData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + list, err := l.svcCtx.Growth.ListUtmLinks(l.ctx, uid) + if err != nil { + return nil, err + } + out := make([]types.UtmLinkPublic, 0, len(list)) + for _, u := range list { + out = append(out, types.UtmLinkPublic{ + Id: u.ID, Code: u.Code, TrackPath: "/api/v1/public/u/" + u.Code, + DestinationUrl: u.DestinationURL, OutcomeId: u.OutcomeID, Label: u.Label, + Clicks: u.Clicks, CreatedAt: u.CreatedAt, + }) + } + return &types.UtmLinkListData{List: out}, nil +} diff --git a/apps/backend/internal/logic/workspaces/add_workspace_member_logic.go b/apps/backend/internal/logic/workspaces/add_workspace_member_logic.go new file mode 100644 index 0000000..6986a8d --- /dev/null +++ b/apps/backend/internal/logic/workspaces/add_workspace_member_logic.go @@ -0,0 +1,56 @@ +package workspaces + +import ( + "context" + "fmt" + "strings" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type AddWorkspaceMemberLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewAddWorkspaceMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AddWorkspaceMemberLogic { + return &AddWorkspaceMemberLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *AddWorkspaceMemberLogic) AddWorkspaceMember(req *types.AddWorkspaceMemberReq) (*types.WorkspaceMemberPublic, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + memberUID := req.MemberUid + email, name := "", "" + if memberUID <= 0 && strings.TrimSpace(req.Email) != "" { + mem, err := l.svcCtx.Members.FindByEmail(l.ctx, strings.TrimSpace(req.Email)) + if err != nil || mem == nil { + return nil, fmt.Errorf("member not found by email") + } + memberUID = mem.UID + email = mem.Email + name = mem.DisplayName + } else if memberUID > 0 { + if mem, err := l.svcCtx.Members.FindByUID(l.ctx, memberUID); err == nil && mem != nil { + email = mem.Email + name = mem.DisplayName + } + } + m, err := l.svcCtx.Growth.AddWorkspaceMember(l.ctx, uid, memberUID, req.WorkspaceId, req.Role) + if err != nil { + return nil, err + } + if name == "" { + name = fmt.Sprintf("uid %d", m.UID) + } + return &types.WorkspaceMemberPublic{ + Uid: m.UID, Email: email, DisplayName: name, Role: m.Role, JoinedAt: m.JoinedAt, + }, nil +} diff --git a/apps/backend/internal/logic/workspaces/approve_draft_review_logic.go b/apps/backend/internal/logic/workspaces/approve_draft_review_logic.go new file mode 100644 index 0000000..42cc6e7 --- /dev/null +++ b/apps/backend/internal/logic/workspaces/approve_draft_review_logic.go @@ -0,0 +1,36 @@ +package workspaces + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + growthDomain "apps/backend/internal/module/growth/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ApproveDraftReviewLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewApproveDraftReviewLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ApproveDraftReviewLogic { + return &ApproveDraftReviewLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ApproveDraftReviewLogic) ApproveDraftReview(req *types.ReviewDecisionReq) (*types.DraftReviewPublic, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + r, err := l.svcCtx.Growth.DecideReview(l.ctx, uid, req.Id, growthDomain.ReviewApproved, req.Reason) + if err != nil { + return nil, err + } + return growthmap.Review(r), nil +} diff --git a/apps/backend/internal/logic/workspaces/archive_workspace_logic.go b/apps/backend/internal/logic/workspaces/archive_workspace_logic.go new file mode 100644 index 0000000..260d447 --- /dev/null +++ b/apps/backend/internal/logic/workspaces/archive_workspace_logic.go @@ -0,0 +1,33 @@ +package workspaces + +import ( + "context" + "fmt" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ArchiveWorkspaceLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewArchiveWorkspaceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ArchiveWorkspaceLogic { + return &ArchiveWorkspaceLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ArchiveWorkspaceLogic) ArchiveWorkspace(req *types.WorkspaceIdReq) (*types.OkData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + if err := l.svcCtx.Growth.ArchiveWorkspace(l.ctx, uid, req.Id); err != nil { + return nil, err + } + return &types.OkData{Ok: true}, nil +} diff --git a/apps/backend/internal/logic/workspaces/create_workspace_logic.go b/apps/backend/internal/logic/workspaces/create_workspace_logic.go new file mode 100644 index 0000000..400a993 --- /dev/null +++ b/apps/backend/internal/logic/workspaces/create_workspace_logic.go @@ -0,0 +1,35 @@ +package workspaces + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type CreateWorkspaceLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateWorkspaceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateWorkspaceLogic { + return &CreateWorkspaceLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *CreateWorkspaceLogic) CreateWorkspace(req *types.CreateWorkspaceReq) (*types.WorkspacePublic, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + w, err := l.svcCtx.Growth.CreateWorkspace(l.ctx, uid, req.Name) + if err != nil { + return nil, err + } + return growthmap.Workspace(w), nil +} diff --git a/apps/backend/internal/logic/workspaces/export_workspace_report_logic.go b/apps/backend/internal/logic/workspaces/export_workspace_report_logic.go new file mode 100644 index 0000000..7735ffa --- /dev/null +++ b/apps/backend/internal/logic/workspaces/export_workspace_report_logic.go @@ -0,0 +1,38 @@ +package workspaces + +import ( + "context" + "fmt" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ExportWorkspaceReportLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewExportWorkspaceReportLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ExportWorkspaceReportLogic { + return &ExportWorkspaceReportLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ExportWorkspaceReportLogic) ExportWorkspaceReport(req *types.ExportReportReq) (*types.ExportReportData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + format := req.Format + if format == "" { + format = "pdf" + } + filename, ct, url, err := l.svcCtx.Growth.ExportReport(l.ctx, uid, req.WorkspaceId, req.YearMonth, format) + if err != nil { + return nil, err + } + return &types.ExportReportData{Url: url, Filename: filename, ContentType: ct, Format: format}, nil +} diff --git a/apps/backend/internal/logic/workspaces/list_draft_reviews_logic.go b/apps/backend/internal/logic/workspaces/list_draft_reviews_logic.go new file mode 100644 index 0000000..611e8af --- /dev/null +++ b/apps/backend/internal/logic/workspaces/list_draft_reviews_logic.go @@ -0,0 +1,38 @@ +package workspaces + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListDraftReviewsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListDraftReviewsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListDraftReviewsLogic { + return &ListDraftReviewsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListDraftReviewsLogic) ListDraftReviews(req *types.ListDraftReviewsReq) (*types.DraftReviewListData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + list, total, err := l.svcCtx.Growth.ListReviews(l.ctx, uid, req.Status, req.Page, req.PageSize) + if err != nil { + return nil, err + } + return &types.DraftReviewListData{ + List: growthmap.ReviewList(list), + Pagination: growthmap.Pagination(req.Page, req.PageSize, total), + }, nil +} diff --git a/apps/backend/internal/logic/workspaces/list_workspace_members_logic.go b/apps/backend/internal/logic/workspaces/list_workspace_members_logic.go new file mode 100644 index 0000000..5512683 --- /dev/null +++ b/apps/backend/internal/logic/workspaces/list_workspace_members_logic.go @@ -0,0 +1,47 @@ +package workspaces + +import ( + "context" + "fmt" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListWorkspaceMembersLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListWorkspaceMembersLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListWorkspaceMembersLogic { + return &ListWorkspaceMembersLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListWorkspaceMembersLogic) ListWorkspaceMembers(req *types.ListWorkspaceMembersReq) (*types.WorkspaceMemberListData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + list, err := l.svcCtx.Growth.ListWorkspaceMembers(l.ctx, uid, req.Id) + if err != nil { + return nil, err + } + out := make([]types.WorkspaceMemberPublic, 0, len(list)) + for _, m := range list { + email, name := "", fmt.Sprintf("uid %d", m.UID) + if mem, err := l.svcCtx.Members.FindByUID(l.ctx, m.UID); err == nil && mem != nil { + email = mem.Email + if mem.DisplayName != "" { + name = mem.DisplayName + } + } + out = append(out, types.WorkspaceMemberPublic{ + Uid: m.UID, Email: email, DisplayName: name, Role: m.Role, JoinedAt: m.JoinedAt, + }) + } + return &types.WorkspaceMemberListData{List: out}, nil +} diff --git a/apps/backend/internal/logic/workspaces/list_workspaces_logic.go b/apps/backend/internal/logic/workspaces/list_workspaces_logic.go new file mode 100644 index 0000000..f6ed390 --- /dev/null +++ b/apps/backend/internal/logic/workspaces/list_workspaces_logic.go @@ -0,0 +1,35 @@ +package workspaces + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListWorkspacesLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListWorkspacesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListWorkspacesLogic { + return &ListWorkspacesLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListWorkspacesLogic) ListWorkspaces() (*types.WorkspaceListData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + list, cur, err := l.svcCtx.Growth.ListWorkspaces(l.ctx, uid) + if err != nil { + return nil, err + } + return &types.WorkspaceListData{List: growthmap.WorkspaceList(list), CurrentWorkspaceId: cur}, nil +} diff --git a/apps/backend/internal/logic/workspaces/reject_draft_review_logic.go b/apps/backend/internal/logic/workspaces/reject_draft_review_logic.go new file mode 100644 index 0000000..7a8e937 --- /dev/null +++ b/apps/backend/internal/logic/workspaces/reject_draft_review_logic.go @@ -0,0 +1,36 @@ +package workspaces + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + growthDomain "apps/backend/internal/module/growth/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RejectDraftReviewLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRejectDraftReviewLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RejectDraftReviewLogic { + return &RejectDraftReviewLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RejectDraftReviewLogic) RejectDraftReview(req *types.ReviewDecisionReq) (*types.DraftReviewPublic, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + r, err := l.svcCtx.Growth.DecideReview(l.ctx, uid, req.Id, growthDomain.ReviewRejected, req.Reason) + if err != nil { + return nil, err + } + return growthmap.Review(r), nil +} diff --git a/apps/backend/internal/logic/workspaces/remove_workspace_member_logic.go b/apps/backend/internal/logic/workspaces/remove_workspace_member_logic.go new file mode 100644 index 0000000..76983b8 --- /dev/null +++ b/apps/backend/internal/logic/workspaces/remove_workspace_member_logic.go @@ -0,0 +1,33 @@ +package workspaces + +import ( + "context" + "fmt" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RemoveWorkspaceMemberLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRemoveWorkspaceMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemoveWorkspaceMemberLogic { + return &RemoveWorkspaceMemberLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RemoveWorkspaceMemberLogic) RemoveWorkspaceMember(req *types.RemoveWorkspaceMemberReq) (*types.OkData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + if err := l.svcCtx.Growth.RemoveWorkspaceMember(l.ctx, uid, req.Uid, req.Id); err != nil { + return nil, err + } + return &types.OkData{Ok: true}, nil +} diff --git a/apps/backend/internal/logic/workspaces/submit_draft_review_logic.go b/apps/backend/internal/logic/workspaces/submit_draft_review_logic.go new file mode 100644 index 0000000..56112e0 --- /dev/null +++ b/apps/backend/internal/logic/workspaces/submit_draft_review_logic.go @@ -0,0 +1,35 @@ +package workspaces + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SubmitDraftReviewLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSubmitDraftReviewLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SubmitDraftReviewLogic { + return &SubmitDraftReviewLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SubmitDraftReviewLogic) SubmitDraftReview(req *types.SubmitDraftReviewReq) (*types.DraftReviewPublic, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + r, err := l.svcCtx.Growth.SubmitReview(l.ctx, uid, req.RefType, req.RefId, req.WorkspaceId) + if err != nil { + return nil, err + } + return growthmap.Review(r), nil +} diff --git a/apps/backend/internal/logic/workspaces/switch_workspace_logic.go b/apps/backend/internal/logic/workspaces/switch_workspace_logic.go new file mode 100644 index 0000000..2043592 --- /dev/null +++ b/apps/backend/internal/logic/workspaces/switch_workspace_logic.go @@ -0,0 +1,38 @@ +package workspaces + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SwitchWorkspaceLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSwitchWorkspaceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SwitchWorkspaceLogic { + return &SwitchWorkspaceLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SwitchWorkspaceLogic) SwitchWorkspace(req *types.SwitchWorkspaceReq) (*types.WorkspaceListData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + if err := l.svcCtx.Growth.SwitchWorkspace(l.ctx, uid, req.Id); err != nil { + return nil, err + } + list, cur, err := l.svcCtx.Growth.ListWorkspaces(l.ctx, uid) + if err != nil { + return nil, err + } + return &types.WorkspaceListData{List: growthmap.WorkspaceList(list), CurrentWorkspaceId: cur}, nil +} diff --git a/apps/backend/internal/logic/workspaces/update_workspace_branding_logic.go b/apps/backend/internal/logic/workspaces/update_workspace_branding_logic.go new file mode 100644 index 0000000..29d2926 --- /dev/null +++ b/apps/backend/internal/logic/workspaces/update_workspace_branding_logic.go @@ -0,0 +1,36 @@ +package workspaces + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpdateWorkspaceBrandingLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateWorkspaceBrandingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateWorkspaceBrandingLogic { + return &UpdateWorkspaceBrandingLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *UpdateWorkspaceBrandingLogic) UpdateWorkspaceBranding(req *types.WorkspaceBrandingReq) (*types.WorkspacePublic, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + hide := req.HidePoweredBy + w, err := l.svcCtx.Growth.UpdateWorkspaceBranding(l.ctx, uid, req.Id, req.FooterText, req.BrandName, &hide) + if err != nil { + return nil, err + } + return growthmap.Workspace(w), nil +} diff --git a/apps/backend/internal/logic/workspaces/update_workspace_logic.go b/apps/backend/internal/logic/workspaces/update_workspace_logic.go new file mode 100644 index 0000000..861c73a --- /dev/null +++ b/apps/backend/internal/logic/workspaces/update_workspace_logic.go @@ -0,0 +1,35 @@ +package workspaces + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/growthmap" + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpdateWorkspaceLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateWorkspaceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateWorkspaceLogic { + return &UpdateWorkspaceLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *UpdateWorkspaceLogic) UpdateWorkspace(req *types.UpdateWorkspaceReq) (*types.WorkspacePublic, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + w, err := l.svcCtx.Growth.UpdateWorkspace(l.ctx, uid, req.Id, req.Name, req.ReviewRequired) + if err != nil { + return nil, err + } + return growthmap.Workspace(w), nil +} diff --git a/apps/backend/internal/module/growth/domain/domain.go b/apps/backend/internal/module/growth/domain/domain.go new file mode 100644 index 0000000..43e245a --- /dev/null +++ b/apps/backend/internal/module/growth/domain/domain.go @@ -0,0 +1,300 @@ +package domain + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" +) + +var ( + ErrNotFound = errors.New("growth not found") + ErrForbidden = errors.New("growth access denied") + ErrValidation = errors.New("growth validation") + ErrThrottle = errors.New("account health throttle") + ErrReviewPending = errors.New("draft review required") + ErrRateLimited = errors.New("checkup regenerate limit") +) + +const ( + SourceScoutOutreach = "scout_outreach" + SourceOutboxStep = "outbox_step" + + KindReach = "reach" + KindReply = "reply" + KindFollow = "follow" + KindConversion = "conversion" + KindInteraction = "interaction" + + ConfidenceConfirmed = "confirmed" + ConfidencePossible = "possible" + + StatusObserving = "observing" + StatusConfirmed = "confirmed" + StatusPossible = "possible" + StatusExpired = "expired" + StatusAmended = "amended" + + CheckupGenerating = "generating" + CheckupReady = "ready" + CheckupFailed = "failed" + + HealthNormal = "normal" + HealthWarn = "warn" + HealthThrottle = "throttle" + + ReviewNone = "none" + ReviewPending = "pending" + ReviewApproved = "approved" + ReviewRejected = "rejected" + + RewardCredited = "credited" + RewardCapped = "capped" + RewardSkipped = "skipped" + + ObserveWindow = 72 * time.Hour +) + +func NowNano() int64 { return time.Now().UTC().UnixNano() } + +func NewID() string { return uuid.NewString() } + +type OutcomeEvent struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + SourceType string `bson:"source_type" json:"source_type"` + SourceID string `bson:"source_id" json:"source_id"` + ThreadsAccountID string `bson:"threads_account_id,omitempty" json:"threads_account_id,omitempty"` + Kind string `bson:"kind" json:"kind"` + Confidence string `bson:"confidence" json:"confidence"` + Status string `bson:"status" json:"status"` + ReplyCountDelta int `bson:"reply_count_delta,omitempty" json:"reply_count_delta,omitempty"` + LikeCountDelta int `bson:"like_count_delta,omitempty" json:"like_count_delta,omitempty"` + FollowersDelta int `bson:"followers_delta,omitempty" json:"followers_delta,omitempty"` + ConversionAmount float64 `bson:"conversion_amount,omitempty" json:"conversion_amount,omitempty"` + ConversionNote string `bson:"conversion_note,omitempty" json:"conversion_note,omitempty"` + ConversionCurrency string `bson:"conversion_currency,omitempty" json:"conversion_currency,omitempty"` + HasConversion bool `bson:"has_conversion" json:"has_conversion"` + ClickCount int `bson:"click_count,omitempty" json:"click_count,omitempty"` + WindowEndsAt int64 `bson:"window_ends_at" json:"window_ends_at"` + SentAt int64 `bson:"sent_at" json:"sent_at"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +const ( + KindClick = "click" + + PlaybookBrief = "brief" + PlaybookPersona = "persona" + PlaybookPlay = "play" + + WSRoleOwner = "owner" + WSRoleEditor = "editor" + WSRoleReviewer = "reviewer" +) + +type Playbook struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + Kind string `bson:"kind" json:"kind"` + Title string `bson:"title" json:"title"` + Niche string `bson:"niche" json:"niche"` + Body string `bson:"body" json:"body"` + Anonymous bool `bson:"anonymous" json:"anonymous"` + ImportCount int `bson:"import_count" json:"import_count"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +type UtmLink struct { + ID string `bson:"_id" json:"id"` + Code string `bson:"code" json:"code"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + DestinationURL string `bson:"destination_url" json:"destination_url"` + OutcomeID string `bson:"outcome_id,omitempty" json:"outcome_id,omitempty"` + Label string `bson:"label,omitempty" json:"label,omitempty"` + Clicks int `bson:"clicks" json:"clicks"` + CreatedAt int64 `bson:"created_at" json:"created_at"` +} + +type WorkspaceMember struct { + ID string `bson:"_id" json:"id"` + WorkspaceID string `bson:"workspace_id" json:"workspace_id"` + UID int64 `bson:"uid" json:"uid"` + Role string `bson:"role" json:"role"` + JoinedAt int64 `bson:"joined_at" json:"joined_at"` +} + +// BenchmarkSample — anonymized contribution for niche medians +type BenchmarkSample struct { + ID string `bson:"_id" json:"id"` + Niche string `bson:"niche" json:"niche"` + EngRate float64 `bson:"eng_rate" json:"eng_rate"` + AvgViews float64 `bson:"avg_views" json:"avg_views"` + OwnerHash string `bson:"owner_hash" json:"owner_hash"` // one sample per owner per niche + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +type OutcomeSummary struct { + From int64 + To int64 + Reach int + Conversations int + FollowsPossible int + FollowsConfirmed int + Conversions int + ConversionAmount float64 +} + +type CheckupFinding struct { + Claim string `bson:"claim" json:"claim"` + Evidence []string `bson:"evidence" json:"evidence"` +} + +type CheckupAction struct { + Title string `bson:"title" json:"title"` + Reason string `bson:"reason" json:"reason"` + Deeplink string `bson:"deeplink" json:"deeplink"` +} + +type WeeklyCheckup struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + WeekKey string `bson:"week_key" json:"week_key"` + Status string `bson:"status" json:"status"` + Summary string `bson:"summary" json:"summary"` + Findings []CheckupFinding `bson:"findings" json:"findings"` + Actions []CheckupAction `bson:"actions" json:"actions"` + Error string `bson:"error,omitempty" json:"error,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +type AccountHealth struct { + ThreadsAccountID string `bson:"_id" json:"threads_account_id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + Score int `bson:"score" json:"score"` + Level string `bson:"level" json:"level"` + Factors []string `bson:"factors" json:"factors"` + Advice string `bson:"advice" json:"advice"` + ComputedAt int64 `bson:"computed_at" json:"computed_at"` +} + +type Workspace struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + Name string `bson:"name" json:"name"` + ReviewRequired bool `bson:"review_required" json:"review_required"` + IsDefault bool `bson:"is_default" json:"is_default"` + Archived bool `bson:"archived" json:"archived"` + FooterText string `bson:"footer_text,omitempty" json:"footer_text,omitempty"` + HidePoweredBy bool `bson:"hide_powered_by,omitempty" json:"hide_powered_by,omitempty"` + BrandName string `bson:"brand_name,omitempty" json:"brand_name,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +type MemberWorkspaceState struct { + OwnerUID int64 `bson:"_id" json:"owner_uid"` + CurrentWorkspaceID string `bson:"current_workspace_id" json:"current_workspace_id"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +type DraftReview struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + WorkspaceID string `bson:"workspace_id" json:"workspace_id"` + RefType string `bson:"ref_type" json:"ref_type"` + RefID string `bson:"ref_id" json:"ref_id"` + Status string `bson:"status" json:"status"` + Reason string `bson:"reason,omitempty" json:"reason,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +type InviteReward struct { + ID string `bson:"_id" json:"id"` + InviterUID int64 `bson:"inviter_uid" json:"inviter_uid"` + InviteeUID int64 `bson:"invitee_uid" json:"invitee_uid"` + PlanID string `bson:"plan_id" json:"plan_id"` + Points int `bson:"points" json:"points"` + Status string `bson:"status" json:"status"` + CreatedAt int64 `bson:"created_at" json:"created_at"` +} + +type OutcomeListFilter struct { + Kind string + Confidence string + SourceType string + From int64 + To int64 + Page int + PageSize int +} + +type Repository interface { + // Outcomes + UpsertOutcomeBySource(ctx context.Context, e *OutcomeEvent) error + GetOutcome(ctx context.Context, id string) (*OutcomeEvent, error) + ListOutcomes(ctx context.Context, ownerUID int64, f OutcomeListFilter) ([]*OutcomeEvent, int64, error) + SaveOutcome(ctx context.Context, e *OutcomeEvent) error + ListObservingDue(ctx context.Context, now int64, limit int) ([]*OutcomeEvent, error) + + // Checkups + SaveCheckup(ctx context.Context, c *WeeklyCheckup) error + GetCheckup(ctx context.Context, id string) (*WeeklyCheckup, error) + LatestCheckup(ctx context.Context, ownerUID int64) (*WeeklyCheckup, error) + ListCheckups(ctx context.Context, ownerUID int64, page, pageSize int) ([]*WeeklyCheckup, int64, error) + CountCheckupGenerations(ctx context.Context, ownerUID int64, sinceNano int64) (int64, error) + + // Health + SaveHealth(ctx context.Context, h *AccountHealth) error + GetHealth(ctx context.Context, accountID string) (*AccountHealth, error) + ListHealth(ctx context.Context, ownerUID int64) ([]*AccountHealth, error) + + // Workspace + SaveWorkspace(ctx context.Context, w *Workspace) error + GetWorkspace(ctx context.Context, id string) (*Workspace, error) + ListWorkspaces(ctx context.Context, ownerUID int64, includeArchived bool) ([]*Workspace, error) + CountActiveWorkspaces(ctx context.Context, ownerUID int64) (int64, error) + GetMemberWSState(ctx context.Context, ownerUID int64) (*MemberWorkspaceState, error) + SaveMemberWSState(ctx context.Context, s *MemberWorkspaceState) error + + // Reviews + SaveReview(ctx context.Context, r *DraftReview) error + GetReview(ctx context.Context, id string) (*DraftReview, error) + FindReviewByRef(ctx context.Context, ownerUID int64, refType, refID string) (*DraftReview, error) + ListReviews(ctx context.Context, ownerUID int64, status string, page, pageSize int) ([]*DraftReview, int64, error) + + // Invite rewards + SaveInviteReward(ctx context.Context, r *InviteReward) error + ListInviteRewards(ctx context.Context, inviterUID int64, limit int) ([]*InviteReward, error) + SumInviteRewardPointsMonth(ctx context.Context, inviterUID int64, monthStartNano int64) (int, error) + SumInviteRewardPointsTotal(ctx context.Context, inviterUID int64) (int, error) + HasInviteRewardForInvitee(ctx context.Context, inviteeUID int64) (bool, error) + + // P2 Playbooks + SavePlaybook(ctx context.Context, p *Playbook) error + GetPlaybook(ctx context.Context, id string) (*Playbook, error) + ListPlaybooks(ctx context.Context, kind, niche string, ownerOnlyUID int64, page, pageSize int) ([]*Playbook, int64, error) + DeletePlaybook(ctx context.Context, id string) error + IncPlaybookImport(ctx context.Context, id string) error + + // P2 UTM + SaveUtmLink(ctx context.Context, u *UtmLink) error + GetUtmByCode(ctx context.Context, code string) (*UtmLink, error) + ListUtmLinks(ctx context.Context, ownerUID int64) ([]*UtmLink, error) + IncUtmClick(ctx context.Context, code string) (*UtmLink, error) + + // P2 Workspace members + SaveWorkspaceMember(ctx context.Context, m *WorkspaceMember) error + ListWorkspaceMembers(ctx context.Context, workspaceID string) ([]*WorkspaceMember, error) + RemoveWorkspaceMember(ctx context.Context, workspaceID string, uid int64) error + IsWorkspaceMember(ctx context.Context, workspaceID string, uid int64) (bool, string, error) + + // P2 Benchmark + UpsertBenchmarkSample(ctx context.Context, s *BenchmarkSample) error + ListBenchmarkSamples(ctx context.Context, niche string, limit int) ([]*BenchmarkSample, error) +} diff --git a/apps/backend/internal/module/growth/repository/memory.go b/apps/backend/internal/module/growth/repository/memory.go new file mode 100644 index 0000000..4cd3910 --- /dev/null +++ b/apps/backend/internal/module/growth/repository/memory.go @@ -0,0 +1,674 @@ +package repository + +import ( + "context" + "sort" + "strings" + "sync" + "time" + + "apps/backend/internal/module/growth/domain" +) + +type Memory struct { + mu sync.Mutex + outcomes map[string]*domain.OutcomeEvent + bySource map[string]string + checkups map[string]*domain.WeeklyCheckup + health map[string]*domain.AccountHealth + workspaces map[string]*domain.Workspace + wsState map[int64]*domain.MemberWorkspaceState + reviews map[string]*domain.DraftReview + rewards map[string]*domain.InviteReward + playbooks map[string]*domain.Playbook + utm map[string]*domain.UtmLink + wsMembers map[string]*domain.WorkspaceMember + bench map[string]*domain.BenchmarkSample +} + +func NewMemory() *Memory { + return &Memory{ + outcomes: map[string]*domain.OutcomeEvent{}, + bySource: map[string]string{}, + checkups: map[string]*domain.WeeklyCheckup{}, + health: map[string]*domain.AccountHealth{}, + workspaces: map[string]*domain.Workspace{}, + wsState: map[int64]*domain.MemberWorkspaceState{}, + reviews: map[string]*domain.DraftReview{}, + rewards: map[string]*domain.InviteReward{}, + playbooks: map[string]*domain.Playbook{}, + utm: map[string]*domain.UtmLink{}, + wsMembers: map[string]*domain.WorkspaceMember{}, + bench: map[string]*domain.BenchmarkSample{}, + } +} + +func sourceKey(t, id string) string { return t + ":" + id } + +func (m *Memory) UpsertOutcomeBySource(_ context.Context, e *domain.OutcomeEvent) error { + m.mu.Lock() + defer m.mu.Unlock() + sk := sourceKey(e.SourceType, e.SourceID) + if id, ok := m.bySource[sk]; ok { + // 發佈 hook 可能重送;已存在就保持原 sent_at 與所有觀測/成交訊號。 + *e = *m.outcomes[id] + return nil + } else if e.ID == "" { + e.ID = domain.NewID() + } + cp := *e + m.outcomes[e.ID] = &cp + m.bySource[sk] = e.ID + return nil +} + +func (m *Memory) GetOutcome(_ context.Context, id string) (*domain.OutcomeEvent, error) { + m.mu.Lock() + defer m.mu.Unlock() + e, ok := m.outcomes[id] + if !ok { + return nil, domain.ErrNotFound + } + cp := *e + return &cp, nil +} + +func (m *Memory) ListOutcomes(_ context.Context, ownerUID int64, f domain.OutcomeListFilter) ([]*domain.OutcomeEvent, int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + var all []*domain.OutcomeEvent + for _, e := range m.outcomes { + if e.OwnerUID != ownerUID { + continue + } + if f.Kind != "" && e.Kind != f.Kind { + continue + } + if f.Confidence != "" && e.Confidence != f.Confidence { + continue + } + if f.SourceType != "" && e.SourceType != f.SourceType { + continue + } + if f.From > 0 && e.SentAt < f.From { + continue + } + if f.To > 0 && e.SentAt > f.To { + continue + } + cp := *e + all = append(all, &cp) + } + sort.Slice(all, func(i, j int) bool { return all[i].SentAt > all[j].SentAt }) + total := int64(len(all)) + page, ps := f.Page, f.PageSize + if page < 1 { + page = 1 + } + if ps < 1 { + ps = 20 + } + start := (page - 1) * ps + if start >= len(all) { + return nil, total, nil + } + end := start + ps + if end > len(all) { + end = len(all) + } + return all[start:end], total, nil +} + +func (m *Memory) SaveOutcome(_ context.Context, e *domain.OutcomeEvent) error { + m.mu.Lock() + defer m.mu.Unlock() + cp := *e + m.outcomes[e.ID] = &cp + m.bySource[sourceKey(e.SourceType, e.SourceID)] = e.ID + return nil +} + +func (m *Memory) ListObservingDue(_ context.Context, now int64, limit int) ([]*domain.OutcomeEvent, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []*domain.OutcomeEvent + for _, e := range m.outcomes { + if e.Status != domain.StatusObserving { + continue + } + cp := *e + out = append(out, &cp) + if limit > 0 && len(out) >= limit { + break + } + } + return out, nil +} + +func (m *Memory) SaveCheckup(_ context.Context, c *domain.WeeklyCheckup) error { + m.mu.Lock() + defer m.mu.Unlock() + cp := *c + m.checkups[c.ID] = &cp + return nil +} + +func (m *Memory) GetCheckup(_ context.Context, id string) (*domain.WeeklyCheckup, error) { + m.mu.Lock() + defer m.mu.Unlock() + c, ok := m.checkups[id] + if !ok { + return nil, domain.ErrNotFound + } + cp := *c + return &cp, nil +} + +func (m *Memory) LatestCheckup(_ context.Context, ownerUID int64) (*domain.WeeklyCheckup, error) { + m.mu.Lock() + defer m.mu.Unlock() + var best *domain.WeeklyCheckup + for _, c := range m.checkups { + if c.OwnerUID != ownerUID { + continue + } + if best == nil || c.CreatedAt > best.CreatedAt { + cp := *c + best = &cp + } + } + if best == nil { + return nil, domain.ErrNotFound + } + return best, nil +} + +func (m *Memory) ListCheckups(_ context.Context, ownerUID int64, page, pageSize int) ([]*domain.WeeklyCheckup, int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + var all []*domain.WeeklyCheckup + for _, c := range m.checkups { + if c.OwnerUID == ownerUID { + cp := *c + all = append(all, &cp) + } + } + sort.Slice(all, func(i, j int) bool { return all[i].CreatedAt > all[j].CreatedAt }) + total := int64(len(all)) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + start := (page - 1) * pageSize + if start >= len(all) { + return nil, total, nil + } + end := start + pageSize + if end > len(all) { + end = len(all) + } + return all[start:end], total, nil +} + +func (m *Memory) CountCheckupGenerations(_ context.Context, ownerUID int64, sinceNano int64) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + var n int64 + for _, c := range m.checkups { + if c.OwnerUID == ownerUID && c.CreatedAt >= sinceNano { + n++ + } + } + return n, nil +} + +func (m *Memory) SaveHealth(_ context.Context, h *domain.AccountHealth) error { + m.mu.Lock() + defer m.mu.Unlock() + cp := *h + m.health[h.ThreadsAccountID] = &cp + return nil +} + +func (m *Memory) GetHealth(_ context.Context, accountID string) (*domain.AccountHealth, error) { + m.mu.Lock() + defer m.mu.Unlock() + h, ok := m.health[accountID] + if !ok { + return nil, domain.ErrNotFound + } + cp := *h + return &cp, nil +} + +func (m *Memory) ListHealth(_ context.Context, ownerUID int64) ([]*domain.AccountHealth, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []*domain.AccountHealth + for _, h := range m.health { + if h.OwnerUID == ownerUID { + cp := *h + out = append(out, &cp) + } + } + return out, nil +} + +func (m *Memory) SaveWorkspace(_ context.Context, w *domain.Workspace) error { + m.mu.Lock() + defer m.mu.Unlock() + cp := *w + m.workspaces[w.ID] = &cp + return nil +} + +func (m *Memory) GetWorkspace(_ context.Context, id string) (*domain.Workspace, error) { + m.mu.Lock() + defer m.mu.Unlock() + w, ok := m.workspaces[id] + if !ok { + return nil, domain.ErrNotFound + } + cp := *w + return &cp, nil +} + +func (m *Memory) ListWorkspaces(_ context.Context, ownerUID int64, includeArchived bool) ([]*domain.Workspace, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []*domain.Workspace + for _, w := range m.workspaces { + if w.OwnerUID != ownerUID { + continue + } + if w.Archived && !includeArchived { + continue + } + cp := *w + out = append(out, &cp) + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt }) + return out, nil +} + +func (m *Memory) CountActiveWorkspaces(_ context.Context, ownerUID int64) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + var n int64 + for _, w := range m.workspaces { + if w.OwnerUID == ownerUID && !w.Archived { + n++ + } + } + return n, nil +} + +func (m *Memory) GetMemberWSState(_ context.Context, ownerUID int64) (*domain.MemberWorkspaceState, error) { + m.mu.Lock() + defer m.mu.Unlock() + s, ok := m.wsState[ownerUID] + if !ok { + return nil, domain.ErrNotFound + } + cp := *s + return &cp, nil +} + +func (m *Memory) SaveMemberWSState(_ context.Context, s *domain.MemberWorkspaceState) error { + m.mu.Lock() + defer m.mu.Unlock() + cp := *s + m.wsState[s.OwnerUID] = &cp + return nil +} + +func (m *Memory) SaveReview(_ context.Context, r *domain.DraftReview) error { + m.mu.Lock() + defer m.mu.Unlock() + cp := *r + m.reviews[r.ID] = &cp + return nil +} + +func (m *Memory) GetReview(_ context.Context, id string) (*domain.DraftReview, error) { + m.mu.Lock() + defer m.mu.Unlock() + r, ok := m.reviews[id] + if !ok { + return nil, domain.ErrNotFound + } + cp := *r + return &cp, nil +} + +func (m *Memory) FindReviewByRef(_ context.Context, ownerUID int64, refType, refID string) (*domain.DraftReview, error) { + m.mu.Lock() + defer m.mu.Unlock() + var best *domain.DraftReview + for _, r := range m.reviews { + if r.OwnerUID == ownerUID && r.RefType == refType && r.RefID == refID { + if best == nil || r.UpdatedAt > best.UpdatedAt { + cp := *r + best = &cp + } + } + } + if best == nil { + return nil, domain.ErrNotFound + } + return best, nil +} + +func (m *Memory) ListReviews(_ context.Context, ownerUID int64, status string, page, pageSize int) ([]*domain.DraftReview, int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + var all []*domain.DraftReview + for _, r := range m.reviews { + if r.OwnerUID != ownerUID { + continue + } + if status != "" && r.Status != status { + continue + } + cp := *r + all = append(all, &cp) + } + total := int64(len(all)) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + start := (page - 1) * pageSize + if start >= len(all) { + return nil, total, nil + } + end := start + pageSize + if end > len(all) { + end = len(all) + } + return all[start:end], total, nil +} + +func (m *Memory) SaveInviteReward(_ context.Context, r *domain.InviteReward) error { + m.mu.Lock() + defer m.mu.Unlock() + cp := *r + m.rewards[r.ID] = &cp + return nil +} + +func (m *Memory) ListInviteRewards(_ context.Context, inviterUID int64, limit int) ([]*domain.InviteReward, error) { + m.mu.Lock() + defer m.mu.Unlock() + var all []*domain.InviteReward + for _, r := range m.rewards { + if r.InviterUID == inviterUID { + cp := *r + all = append(all, &cp) + } + } + sort.Slice(all, func(i, j int) bool { return all[i].CreatedAt > all[j].CreatedAt }) + if limit > 0 && len(all) > limit { + all = all[:limit] + } + return all, nil +} + +func (m *Memory) SumInviteRewardPointsMonth(_ context.Context, inviterUID int64, monthStartNano int64) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + sum := 0 + for _, r := range m.rewards { + if r.InviterUID == inviterUID && r.Status == domain.RewardCredited && r.CreatedAt >= monthStartNano { + sum += r.Points + } + } + return sum, nil +} + +func (m *Memory) SumInviteRewardPointsTotal(_ context.Context, inviterUID int64) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + sum := 0 + for _, r := range m.rewards { + if r.InviterUID == inviterUID && r.Status == domain.RewardCredited { + sum += r.Points + } + } + return sum, nil +} + +func (m *Memory) HasInviteRewardForInvitee(_ context.Context, inviteeUID int64) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, r := range m.rewards { + if r.InviteeUID == inviteeUID && (r.Status == domain.RewardCredited || r.Status == domain.RewardCapped) { + return true, nil + } + } + return false, nil +} + +// Ensure compile-time interface +var _ domain.Repository = (*Memory)(nil) + +// MonthStartNano helper for tests +func MonthStartNano(t time.Time) int64 { + u := t.UTC() + return time.Date(u.Year(), u.Month(), 1, 0, 0, 0, 0, time.UTC).UnixNano() +} + +// --- P2 --- + +func (m *Memory) SavePlaybook(_ context.Context, p *domain.Playbook) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.playbooks == nil { + m.playbooks = map[string]*domain.Playbook{} + } + cp := *p + m.playbooks[p.ID] = &cp + return nil +} + +func (m *Memory) GetPlaybook(_ context.Context, id string) (*domain.Playbook, error) { + m.mu.Lock() + defer m.mu.Unlock() + p, ok := m.playbooks[id] + if !ok { + return nil, domain.ErrNotFound + } + cp := *p + return &cp, nil +} + +func (m *Memory) ListPlaybooks(_ context.Context, kind, niche string, ownerOnlyUID int64, page, pageSize int) ([]*domain.Playbook, int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + var all []*domain.Playbook + for _, p := range m.playbooks { + if kind != "" && p.Kind != kind { + continue + } + if niche != "" && !stringsContainsFold(p.Niche, niche) { + continue + } + if ownerOnlyUID > 0 && p.OwnerUID != ownerOnlyUID { + continue + } + cp := *p + all = append(all, &cp) + } + sort.Slice(all, func(i, j int) bool { return all[i].CreatedAt > all[j].CreatedAt }) + total := int64(len(all)) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + start := (page - 1) * pageSize + if start >= len(all) { + return nil, total, nil + } + end := start + pageSize + if end > len(all) { + end = len(all) + } + return all[start:end], total, nil +} + +func stringsContainsFold(s, sub string) bool { + return strings.Contains(strings.ToLower(s), strings.ToLower(sub)) +} + +func (m *Memory) DeletePlaybook(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.playbooks[id]; !ok { + return domain.ErrNotFound + } + delete(m.playbooks, id) + return nil +} + +func (m *Memory) IncPlaybookImport(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + p, ok := m.playbooks[id] + if !ok { + return domain.ErrNotFound + } + p.ImportCount++ + return nil +} + +func (m *Memory) SaveUtmLink(_ context.Context, u *domain.UtmLink) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.utm == nil { + m.utm = map[string]*domain.UtmLink{} + } + cp := *u + m.utm[u.Code] = &cp + return nil +} + +func (m *Memory) GetUtmByCode(_ context.Context, code string) (*domain.UtmLink, error) { + m.mu.Lock() + defer m.mu.Unlock() + u, ok := m.utm[code] + if !ok { + return nil, domain.ErrNotFound + } + cp := *u + return &cp, nil +} + +func (m *Memory) ListUtmLinks(_ context.Context, ownerUID int64) ([]*domain.UtmLink, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []*domain.UtmLink + for _, u := range m.utm { + if u.OwnerUID == ownerUID { + cp := *u + out = append(out, &cp) + } + } + return out, nil +} + +func (m *Memory) IncUtmClick(_ context.Context, code string) (*domain.UtmLink, error) { + m.mu.Lock() + defer m.mu.Unlock() + u, ok := m.utm[code] + if !ok { + return nil, domain.ErrNotFound + } + u.Clicks++ + cp := *u + return &cp, nil +} + +func (m *Memory) SaveWorkspaceMember(_ context.Context, mem *domain.WorkspaceMember) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.wsMembers == nil { + m.wsMembers = map[string]*domain.WorkspaceMember{} + } + cp := *mem + m.wsMembers[mem.ID] = &cp + return nil +} + +func (m *Memory) ListWorkspaceMembers(_ context.Context, workspaceID string) ([]*domain.WorkspaceMember, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []*domain.WorkspaceMember + for _, mem := range m.wsMembers { + if mem.WorkspaceID == workspaceID { + cp := *mem + out = append(out, &cp) + } + } + return out, nil +} + +func (m *Memory) RemoveWorkspaceMember(_ context.Context, workspaceID string, uid int64) error { + m.mu.Lock() + defer m.mu.Unlock() + for id, mem := range m.wsMembers { + if mem.WorkspaceID == workspaceID && mem.UID == uid { + delete(m.wsMembers, id) + return nil + } + } + return domain.ErrNotFound +} + +func (m *Memory) IsWorkspaceMember(_ context.Context, workspaceID string, uid int64) (bool, string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if w, ok := m.workspaces[workspaceID]; ok && w.OwnerUID == uid { + return true, domain.WSRoleOwner, nil + } + for _, mem := range m.wsMembers { + if mem.WorkspaceID == workspaceID && mem.UID == uid { + return true, mem.Role, nil + } + } + return false, "", nil +} + +func (m *Memory) UpsertBenchmarkSample(_ context.Context, s *domain.BenchmarkSample) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.bench == nil { + m.bench = map[string]*domain.BenchmarkSample{} + } + key := s.Niche + ":" + s.OwnerHash + s.ID = key + cp := *s + m.bench[key] = &cp + return nil +} + +func (m *Memory) ListBenchmarkSamples(_ context.Context, niche string, limit int) ([]*domain.BenchmarkSample, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []*domain.BenchmarkSample + for _, s := range m.bench { + if niche == "" || s.Niche == niche { + cp := *s + out = append(out, &cp) + } + } + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return out, nil +} diff --git a/apps/backend/internal/module/growth/repository/mongo.go b/apps/backend/internal/module/growth/repository/mongo.go new file mode 100644 index 0000000..ab1a374 --- /dev/null +++ b/apps/backend/internal/module/growth/repository/mongo.go @@ -0,0 +1,473 @@ +package repository + +import ( + "context" + + libmongo "apps/backend/internal/lib/mongo" + "apps/backend/internal/module/growth/domain" + + "github.com/zeromicro/go-zero/core/stores/mon" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type MonStore struct { + outcomes *mon.Model + checkups *mon.Model + health *mon.Model + workspaces *mon.Model + wsState *mon.Model + reviews *mon.Model + rewards *mon.Model + playbooks *mon.Model + utm *mon.Model + wsMembers *mon.Model + bench *mon.Model +} + +func NewMonStore(uri, database string) *MonStore { + uri = libmongo.MustMongoURI(uri) + return &MonStore{ + outcomes: mon.MustNewModel(uri, database, "growth_outcomes"), + checkups: mon.MustNewModel(uri, database, "growth_checkups"), + health: mon.MustNewModel(uri, database, "growth_account_health"), + workspaces: mon.MustNewModel(uri, database, "growth_workspaces"), + wsState: mon.MustNewModel(uri, database, "growth_ws_state"), + reviews: mon.MustNewModel(uri, database, "growth_draft_reviews"), + rewards: mon.MustNewModel(uri, database, "growth_invite_rewards"), + playbooks: mon.MustNewModel(uri, database, "growth_playbooks"), + utm: mon.MustNewModel(uri, database, "growth_utm_links"), + wsMembers: mon.MustNewModel(uri, database, "growth_ws_members"), + bench: mon.MustNewModel(uri, database, "growth_benchmark"), + } +} + +func (s *MonStore) UpsertOutcomeBySource(ctx context.Context, e *domain.OutcomeEvent) error { + filter := bson.M{"owner_uid": e.OwnerUID, "source_type": e.SourceType, "source_id": e.SourceID} + var existing domain.OutcomeEvent + err := s.outcomes.FindOne(ctx, &existing, filter) + if err == nil { + // 發佈 hook 可能重送;已存在就保持原 sent_at 與所有觀測/成交訊號。 + *e = existing + return nil + } + if err != mon.ErrNotFound { + return err + } + if e.ID == "" { + e.ID = domain.NewID() + } + _, err = s.outcomes.InsertOne(ctx, e) + return err +} + +func (s *MonStore) GetOutcome(ctx context.Context, id string) (*domain.OutcomeEvent, error) { + var e domain.OutcomeEvent + err := s.outcomes.FindOne(ctx, &e, bson.M{"_id": id}) + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + if err != nil { + return nil, err + } + return &e, nil +} + +func (s *MonStore) ListOutcomes(ctx context.Context, ownerUID int64, f domain.OutcomeListFilter) ([]*domain.OutcomeEvent, int64, error) { + q := bson.M{"owner_uid": ownerUID} + if f.Kind != "" { + q["kind"] = f.Kind + } + if f.Confidence != "" { + q["confidence"] = f.Confidence + } + if f.SourceType != "" { + q["source_type"] = f.SourceType + } + if f.From > 0 || f.To > 0 { + rangeQ := bson.M{} + if f.From > 0 { + rangeQ["$gte"] = f.From + } + if f.To > 0 { + rangeQ["$lte"] = f.To + } + q["sent_at"] = rangeQ + } + total, err := s.outcomes.CountDocuments(ctx, q) + if err != nil { + return nil, 0, err + } + page, ps := f.Page, f.PageSize + if page < 1 { + page = 1 + } + if ps < 1 { + ps = 20 + } + var list []*domain.OutcomeEvent + err = s.outcomes.Find(ctx, &list, q, options.Find(). + SetSort(bson.D{{Key: "sent_at", Value: -1}}). + SetSkip(int64((page-1)*ps)). + SetLimit(int64(ps))) + return list, total, err +} + +func (s *MonStore) SaveOutcome(ctx context.Context, e *domain.OutcomeEvent) error { + _, err := s.outcomes.UpdateOne(ctx, bson.M{"_id": e.ID}, bson.M{"$set": e}, options.Update().SetUpsert(true)) + return err +} + +func (s *MonStore) ListObservingDue(ctx context.Context, now int64, limit int) ([]*domain.OutcomeEvent, error) { + var list []*domain.OutcomeEvent + opts := options.Find().SetSort(bson.D{{Key: "sent_at", Value: 1}}) + if limit > 0 { + opts.SetLimit(int64(limit)) + } + err := s.outcomes.Find(ctx, &list, bson.M{"status": domain.StatusObserving}, opts) + return list, err +} + +func (s *MonStore) SaveCheckup(ctx context.Context, c *domain.WeeklyCheckup) error { + _, err := s.checkups.UpdateOne(ctx, bson.M{"_id": c.ID}, bson.M{"$set": c}, options.Update().SetUpsert(true)) + return err +} + +func (s *MonStore) GetCheckup(ctx context.Context, id string) (*domain.WeeklyCheckup, error) { + var c domain.WeeklyCheckup + err := s.checkups.FindOne(ctx, &c, bson.M{"_id": id}) + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return &c, err +} + +func (s *MonStore) LatestCheckup(ctx context.Context, ownerUID int64) (*domain.WeeklyCheckup, error) { + var list []*domain.WeeklyCheckup + err := s.checkups.Find(ctx, &list, bson.M{"owner_uid": ownerUID}, + options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}).SetLimit(1)) + if err != nil { + return nil, err + } + if len(list) == 0 { + return nil, domain.ErrNotFound + } + return list[0], nil +} + +func (s *MonStore) ListCheckups(ctx context.Context, ownerUID int64, page, pageSize int) ([]*domain.WeeklyCheckup, int64, error) { + q := bson.M{"owner_uid": ownerUID} + total, err := s.checkups.CountDocuments(ctx, q) + if err != nil { + return nil, 0, err + } + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + var list []*domain.WeeklyCheckup + err = s.checkups.Find(ctx, &list, q, options.Find(). + SetSort(bson.D{{Key: "created_at", Value: -1}}). + SetSkip(int64((page-1)*pageSize)).SetLimit(int64(pageSize))) + return list, total, err +} + +func (s *MonStore) CountCheckupGenerations(ctx context.Context, ownerUID int64, sinceNano int64) (int64, error) { + return s.checkups.CountDocuments(ctx, bson.M{"owner_uid": ownerUID, "created_at": bson.M{"$gte": sinceNano}}) +} + +func (s *MonStore) SaveHealth(ctx context.Context, h *domain.AccountHealth) error { + _, err := s.health.UpdateOne(ctx, bson.M{"_id": h.ThreadsAccountID}, bson.M{"$set": h}, options.Update().SetUpsert(true)) + return err +} + +func (s *MonStore) GetHealth(ctx context.Context, accountID string) (*domain.AccountHealth, error) { + var h domain.AccountHealth + err := s.health.FindOne(ctx, &h, bson.M{"_id": accountID}) + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return &h, err +} + +func (s *MonStore) ListHealth(ctx context.Context, ownerUID int64) ([]*domain.AccountHealth, error) { + var list []*domain.AccountHealth + err := s.health.Find(ctx, &list, bson.M{"owner_uid": ownerUID}) + return list, err +} + +func (s *MonStore) SaveWorkspace(ctx context.Context, w *domain.Workspace) error { + _, err := s.workspaces.UpdateOne(ctx, bson.M{"_id": w.ID}, bson.M{"$set": w}, options.Update().SetUpsert(true)) + return err +} + +func (s *MonStore) GetWorkspace(ctx context.Context, id string) (*domain.Workspace, error) { + var w domain.Workspace + err := s.workspaces.FindOne(ctx, &w, bson.M{"_id": id}) + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return &w, err +} + +func (s *MonStore) ListWorkspaces(ctx context.Context, ownerUID int64, includeArchived bool) ([]*domain.Workspace, error) { + q := bson.M{"owner_uid": ownerUID} + if !includeArchived { + q["archived"] = bson.M{"$ne": true} + } + var list []*domain.Workspace + err := s.workspaces.Find(ctx, &list, q, options.Find().SetSort(bson.D{{Key: "created_at", Value: 1}})) + return list, err +} + +func (s *MonStore) CountActiveWorkspaces(ctx context.Context, ownerUID int64) (int64, error) { + return s.workspaces.CountDocuments(ctx, bson.M{"owner_uid": ownerUID, "archived": bson.M{"$ne": true}}) +} + +func (s *MonStore) GetMemberWSState(ctx context.Context, ownerUID int64) (*domain.MemberWorkspaceState, error) { + var st domain.MemberWorkspaceState + err := s.wsState.FindOne(ctx, &st, bson.M{"_id": ownerUID}) + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return &st, err +} + +func (s *MonStore) SaveMemberWSState(ctx context.Context, st *domain.MemberWorkspaceState) error { + _, err := s.wsState.UpdateOne(ctx, bson.M{"_id": st.OwnerUID}, bson.M{"$set": st}, options.Update().SetUpsert(true)) + return err +} + +func (s *MonStore) SaveReview(ctx context.Context, r *domain.DraftReview) error { + _, err := s.reviews.UpdateOne(ctx, bson.M{"_id": r.ID}, bson.M{"$set": r}, options.Update().SetUpsert(true)) + return err +} + +func (s *MonStore) GetReview(ctx context.Context, id string) (*domain.DraftReview, error) { + var r domain.DraftReview + err := s.reviews.FindOne(ctx, &r, bson.M{"_id": id}) + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return &r, err +} + +func (s *MonStore) FindReviewByRef(ctx context.Context, ownerUID int64, refType, refID string) (*domain.DraftReview, error) { + var list []*domain.DraftReview + err := s.reviews.Find(ctx, &list, bson.M{"owner_uid": ownerUID, "ref_type": refType, "ref_id": refID}, + options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}}).SetLimit(1)) + if err != nil { + return nil, err + } + if len(list) == 0 { + return nil, domain.ErrNotFound + } + return list[0], nil +} + +func (s *MonStore) ListReviews(ctx context.Context, ownerUID int64, status string, page, pageSize int) ([]*domain.DraftReview, int64, error) { + q := bson.M{"owner_uid": ownerUID} + if status != "" { + q["status"] = status + } + total, err := s.reviews.CountDocuments(ctx, q) + if err != nil { + return nil, 0, err + } + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + var list []*domain.DraftReview + err = s.reviews.Find(ctx, &list, q, options.Find().SetSkip(int64((page-1)*pageSize)).SetLimit(int64(pageSize))) + return list, total, err +} + +func (s *MonStore) SaveInviteReward(ctx context.Context, r *domain.InviteReward) error { + _, err := s.rewards.InsertOne(ctx, r) + return err +} + +func (s *MonStore) ListInviteRewards(ctx context.Context, inviterUID int64, limit int) ([]*domain.InviteReward, error) { + opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}) + if limit > 0 { + opts.SetLimit(int64(limit)) + } + var list []*domain.InviteReward + err := s.rewards.Find(ctx, &list, bson.M{"inviter_uid": inviterUID}, opts) + return list, err +} + +func (s *MonStore) SumInviteRewardPointsMonth(ctx context.Context, inviterUID int64, monthStartNano int64) (int, error) { + var list []*domain.InviteReward + err := s.rewards.Find(ctx, &list, bson.M{ + "inviter_uid": inviterUID, + "status": domain.RewardCredited, + "created_at": bson.M{"$gte": monthStartNano}, + }) + if err != nil { + return 0, err + } + sum := 0 + for _, r := range list { + sum += r.Points + } + return sum, nil +} + +func (s *MonStore) SumInviteRewardPointsTotal(ctx context.Context, inviterUID int64) (int, error) { + var list []*domain.InviteReward + err := s.rewards.Find(ctx, &list, bson.M{"inviter_uid": inviterUID, "status": domain.RewardCredited}) + if err != nil { + return 0, err + } + sum := 0 + for _, r := range list { + sum += r.Points + } + return sum, nil +} + +func (s *MonStore) HasInviteRewardForInvitee(ctx context.Context, inviteeUID int64) (bool, error) { + n, err := s.rewards.CountDocuments(ctx, bson.M{ + "invitee_uid": inviteeUID, + "status": bson.M{"$in": []string{domain.RewardCredited, domain.RewardCapped}}, + }) + return n > 0, err +} + +var _ domain.Repository = (*MonStore)(nil) + +func (s *MonStore) SavePlaybook(ctx context.Context, p *domain.Playbook) error { + _, err := s.playbooks.UpdateOne(ctx, bson.M{"_id": p.ID}, bson.M{"$set": p}, options.Update().SetUpsert(true)) + return err +} +func (s *MonStore) GetPlaybook(ctx context.Context, id string) (*domain.Playbook, error) { + var p domain.Playbook + err := s.playbooks.FindOne(ctx, &p, bson.M{"_id": id}) + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return &p, err +} +func (s *MonStore) ListPlaybooks(ctx context.Context, kind, niche string, ownerOnlyUID int64, page, pageSize int) ([]*domain.Playbook, int64, error) { + q := bson.M{} + if kind != "" { + q["kind"] = kind + } + if niche != "" { + q["niche"] = bson.M{"$regex": niche, "$options": "i"} + } + if ownerOnlyUID > 0 { + q["owner_uid"] = ownerOnlyUID + } + total, err := s.playbooks.CountDocuments(ctx, q) + if err != nil { + return nil, 0, err + } + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + var list []*domain.Playbook + err = s.playbooks.Find(ctx, &list, q, options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}).SetSkip(int64((page-1)*pageSize)).SetLimit(int64(pageSize))) + return list, total, err +} +func (s *MonStore) DeletePlaybook(ctx context.Context, id string) error { + res, err := s.playbooks.DeleteOne(ctx, bson.M{"_id": id}) + if err != nil { + return err + } + if res == 0 { + return domain.ErrNotFound + } + return nil +} +func (s *MonStore) IncPlaybookImport(ctx context.Context, id string) error { + _, err := s.playbooks.UpdateOne(ctx, bson.M{"_id": id}, bson.M{"$inc": bson.M{"import_count": 1}}) + return err +} +func (s *MonStore) SaveUtmLink(ctx context.Context, u *domain.UtmLink) error { + _, err := s.utm.UpdateOne(ctx, bson.M{"_id": u.ID}, bson.M{"$set": u}, options.Update().SetUpsert(true)) + return err +} +func (s *MonStore) GetUtmByCode(ctx context.Context, code string) (*domain.UtmLink, error) { + var u domain.UtmLink + err := s.utm.FindOne(ctx, &u, bson.M{"code": code}) + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return &u, err +} +func (s *MonStore) ListUtmLinks(ctx context.Context, ownerUID int64) ([]*domain.UtmLink, error) { + var list []*domain.UtmLink + err := s.utm.Find(ctx, &list, bson.M{"owner_uid": ownerUID}, options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})) + return list, err +} +func (s *MonStore) IncUtmClick(ctx context.Context, code string) (*domain.UtmLink, error) { + _, err := s.utm.UpdateOne(ctx, bson.M{"code": code}, bson.M{"$inc": bson.M{"clicks": 1}}) + if err != nil { + return nil, err + } + return s.GetUtmByCode(ctx, code) +} +func (s *MonStore) SaveWorkspaceMember(ctx context.Context, m *domain.WorkspaceMember) error { + _, err := s.wsMembers.UpdateOne(ctx, bson.M{"_id": m.ID}, bson.M{"$set": m}, options.Update().SetUpsert(true)) + return err +} +func (s *MonStore) ListWorkspaceMembers(ctx context.Context, workspaceID string) ([]*domain.WorkspaceMember, error) { + var list []*domain.WorkspaceMember + err := s.wsMembers.Find(ctx, &list, bson.M{"workspace_id": workspaceID}) + return list, err +} +func (s *MonStore) RemoveWorkspaceMember(ctx context.Context, workspaceID string, uid int64) error { + res, err := s.wsMembers.DeleteOne(ctx, bson.M{"workspace_id": workspaceID, "uid": uid}) + if err != nil { + return err + } + if res == 0 { + return domain.ErrNotFound + } + return nil +} +func (s *MonStore) IsWorkspaceMember(ctx context.Context, workspaceID string, uid int64) (bool, string, error) { + w, err := s.GetWorkspace(ctx, workspaceID) + if err == nil && w.OwnerUID == uid { + return true, domain.WSRoleOwner, nil + } + var list []*domain.WorkspaceMember + err = s.wsMembers.Find(ctx, &list, bson.M{"workspace_id": workspaceID, "uid": uid}, options.Find().SetLimit(1)) + if err != nil { + return false, "", err + } + if len(list) == 0 { + return false, "", nil + } + return true, list[0].Role, nil +} +func (s *MonStore) UpsertBenchmarkSample(ctx context.Context, sample *domain.BenchmarkSample) error { + if sample.ID == "" { + sample.ID = sample.Niche + ":" + sample.OwnerHash + } + _, err := s.bench.UpdateOne(ctx, bson.M{"_id": sample.ID}, bson.M{"$set": sample}, options.Update().SetUpsert(true)) + return err +} +func (s *MonStore) ListBenchmarkSamples(ctx context.Context, niche string, limit int) ([]*domain.BenchmarkSample, error) { + q := bson.M{} + if niche != "" { + q["niche"] = niche + } + opts := options.Find() + if limit > 0 { + opts.SetLimit(int64(limit)) + } + var list []*domain.BenchmarkSample + err := s.bench.Find(ctx, &list, q, opts) + return list, err +} diff --git a/apps/backend/internal/module/growth/usecase/p2.go b/apps/backend/internal/module/growth/usecase/p2.go new file mode 100644 index 0000000..f9663db --- /dev/null +++ b/apps/backend/internal/module/growth/usecase/p2.go @@ -0,0 +1,511 @@ +package usecase + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "math" + "sort" + "strings" + "unicode" + + "apps/backend/internal/module/growth/domain" +) + +// --- Playbooks --- + +func (s *Service) PublishPlaybook(ctx context.Context, ownerUID int64, kind, title, niche, body string, anonymous bool) (*domain.Playbook, error) { + kind = strings.ToLower(strings.TrimSpace(kind)) + title = strings.TrimSpace(title) + body = strings.TrimSpace(body) + if title == "" || body == "" { + return nil, fmt.Errorf("%w: title and body required", domain.ErrValidation) + } + switch kind { + case domain.PlaybookBrief, domain.PlaybookPersona, domain.PlaybookPlay: + default: + return nil, fmt.Errorf("%w: kind must be brief|persona|play", domain.ErrValidation) + } + now := domain.NowNano() + p := &domain.Playbook{ + ID: domain.NewID(), OwnerUID: ownerUID, Kind: kind, Title: title, + Niche: strings.TrimSpace(niche), Body: body, Anonymous: anonymous, + CreatedAt: now, UpdatedAt: now, + } + if err := s.Repo.SavePlaybook(ctx, p); err != nil { + return nil, err + } + return p, nil +} + +func (s *Service) ListPlaybooks(ctx context.Context, viewerUID int64, kind, niche string, mine bool, page, pageSize int) ([]*domain.Playbook, int64, error) { + ownerOnly := int64(0) + if mine { + ownerOnly = viewerUID + } + return s.Repo.ListPlaybooks(ctx, kind, niche, ownerOnly, page, pageSize) +} + +func (s *Service) GetPlaybook(ctx context.Context, id string) (*domain.Playbook, error) { + return s.Repo.GetPlaybook(ctx, id) +} + +func (s *Service) ImportPlaybook(ctx context.Context, viewerUID int64, id string) (*domain.Playbook, error) { + p, err := s.Repo.GetPlaybook(ctx, id) + if err != nil { + return nil, err + } + _ = s.Repo.IncPlaybookImport(ctx, id) + // return a personal copy metadata (caller may store locally) + now := domain.NowNano() + copyPB := &domain.Playbook{ + ID: domain.NewID(), OwnerUID: viewerUID, Kind: p.Kind, + Title: p.Title + "(引用)", Niche: p.Niche, Body: p.Body, + Anonymous: false, CreatedAt: now, UpdatedAt: now, + } + if err := s.Repo.SavePlaybook(ctx, copyPB); err != nil { + return nil, err + } + return copyPB, nil +} + +func (s *Service) RemovePlaybook(ctx context.Context, ownerUID int64, id string) error { + p, err := s.Repo.GetPlaybook(ctx, id) + if err != nil { + return err + } + if p.OwnerUID != ownerUID { + return domain.ErrForbidden + } + return s.Repo.DeletePlaybook(ctx, id) +} + +func PlaybookAuthorLabel(p *domain.Playbook, viewerUID int64) string { + if p == nil { + return "" + } + if p.OwnerUID == viewerUID { + return "我" + } + if p.Anonymous { + return "匿名島民" + } + return fmt.Sprintf("島民#%d", p.OwnerUID%10000) +} + +// --- Free tools --- + +func StyleQuiz(samples string) (tone, rhythm, hooks, avoid, summary string, tags []string) { + text := strings.TrimSpace(samples) + if text == "" { + return "平實", "中等", "問題開頭", "避免空泛口號", "請貼上幾則你的貼文以分析風格。", []string{"待補充"} + } + runes := []rune(text) + excl := strings.Count(text, "!") + strings.Count(text, "!") + q := strings.Count(text, "?") + strings.Count(text, "?") + avgLen := 0 + lines := strings.FieldsFunc(text, func(r rune) bool { return r == '\n' || r == '。' }) + if len(lines) > 0 { + total := 0 + for _, ln := range lines { + total += len([]rune(ln)) + } + avgLen = total / len(lines) + } + tone = "溫和敘事" + if excl > 3 { + tone = "熱情有力" + } else if q > 3 { + tone = "對話邀請" + } + rhythm = "中短句" + if avgLen > 40 { + rhythm = "長句鋪陳" + } else if avgLen < 15 { + rhythm = "短句節奏" + } + hooks = "經驗分享" + if q > 0 { + hooks = "提問開場" + } + avoid = "避免罐頭 AI 腔與過度標點" + if excl > 5 { + avoid = "注意驚嘆號密度,避免像廣告" + } + tags = []string{tone, rhythm, hooks} + summary = fmt.Sprintf("從約 %d 字樣本看:語氣偏「%s」,節奏「%s」,常用「%s」。", len(runes), tone, rhythm, hooks) + return +} + +func PainKeywords(productBrief, audience string) (keywords, pains, scan []string, summary string) { + brief := strings.TrimSpace(productBrief) + if brief == "" { + return []string{"請描述產品"}, []string{"痛點待填"}, []string{"關鍵字"}, "請填產品簡述。" + } + // rule-based extraction: split CJK/words + tokens := tokenizeTC(brief) + for _, t := range tokens { + if len([]rune(t)) < 2 { + continue + } + keywords = append(keywords, t) + if len(keywords) >= 12 { + break + } + } + pains = []string{ + "不知道怎麼選", + "用了沒感覺", + "怕踩雷", + "預算有限", + } + if audience != "" { + pains = append([]string{audience + "常遇到的困擾"}, pains...) + } + scan = append([]string{}, keywords...) + for _, p := range pains { + scan = append(scan, p) + } + if len(scan) > 16 { + scan = scan[:16] + } + summary = fmt.Sprintf("依產品描述抽出 %d 個掃描詞、%d 個痛點方向,可直接貼進海巡 brief。", len(keywords), len(pains)) + return +} + +func tokenizeTC(s string) []string { + var cur strings.Builder + var out []string + flush := func() { + t := strings.TrimSpace(cur.String()) + cur.Reset() + if t != "" { + out = append(out, t) + } + } + for _, r := range s { + if unicode.Is(unicode.Han, r) { + cur.WriteRune(r) + if len([]rune(cur.String())) >= 4 { + flush() + } + continue + } + if unicode.IsLetter(r) || unicode.IsDigit(r) { + cur.WriteRune(r) + continue + } + flush() + } + flush() + // also bigrams from han + han := make([]rune, 0) + for _, r := range s { + if unicode.Is(unicode.Han, r) { + han = append(han, r) + } + } + for i := 0; i+1 < len(han); i++ { + out = append(out, string(han[i:i+2])) + } + // dedupe preserve order + seen := map[string]bool{} + var uniq []string + for _, t := range out { + if seen[t] { + continue + } + seen[t] = true + uniq = append(uniq, t) + } + return uniq +} + +// --- UTM --- + +func (s *Service) CreateUtmLink(ctx context.Context, ownerUID int64, dest, outcomeID, label string) (*domain.UtmLink, error) { + dest = strings.TrimSpace(dest) + if dest == "" || !strings.HasPrefix(dest, "http") { + return nil, fmt.Errorf("%w: destination_url must be http(s)", domain.ErrValidation) + } + code := domain.NewID()[:10] + u := &domain.UtmLink{ + ID: domain.NewID(), Code: code, OwnerUID: ownerUID, + DestinationURL: dest, OutcomeID: strings.TrimSpace(outcomeID), + Label: strings.TrimSpace(label), CreatedAt: domain.NowNano(), + } + if err := s.Repo.SaveUtmLink(ctx, u); err != nil { + return nil, err + } + return u, nil +} + +func (s *Service) ListUtmLinks(ctx context.Context, ownerUID int64) ([]*domain.UtmLink, error) { + return s.Repo.ListUtmLinks(ctx, ownerUID) +} + +func (s *Service) TrackUtmClick(ctx context.Context, code string) (dest string, err error) { + u, err := s.Repo.IncUtmClick(ctx, code) + if err != nil { + return "", err + } + if u.OutcomeID != "" { + if e, gerr := s.Repo.GetOutcome(ctx, u.OutcomeID); gerr == nil && e != nil { + e.ClickCount++ + if e.Kind == domain.KindReach || e.Kind == "" { + e.Kind = domain.KindClick + e.Confidence = domain.ConfidenceConfirmed + e.Status = domain.StatusConfirmed + } + e.UpdatedAt = domain.NowNano() + _ = s.Repo.SaveOutcome(ctx, e) + } + } + return u.DestinationURL, nil +} + +// --- Benchmark --- + +func (s *Service) ContributeBenchmark(ctx context.Context, ownerUID int64, niche string, engRate, avgViews float64) error { + niche = strings.TrimSpace(niche) + if niche == "" { + niche = "general" + } + h := sha256.Sum256([]byte(fmt.Sprintf("%d|%s", ownerUID, niche))) + return s.Repo.UpsertBenchmarkSample(ctx, &domain.BenchmarkSample{ + Niche: niche, EngRate: engRate, AvgViews: avgViews, + OwnerHash: hex.EncodeToString(h[:8]), UpdatedAt: domain.NowNano(), + }) +} + +func (s *Service) GetBenchmark(ctx context.Context, niche string, yourEng, yourViews float64) (sampleSize int, medianEng, medianViews float64, available bool, hint string) { + niche = strings.TrimSpace(niche) + if niche == "" { + niche = "general" + } + list, err := s.Repo.ListBenchmarkSamples(ctx, niche, 500) + if err != nil || len(list) < 5 { + return len(list), 0, 0, false, "樣本不足 5,暫不顯示中位數(多貢獻幾次成效後再看)" + } + engs := make([]float64, 0, len(list)) + views := make([]float64, 0, len(list)) + for _, s0 := range list { + engs = append(engs, s0.EngRate) + views = append(views, s0.AvgViews) + } + medianEng = medianFloat(engs) + medianViews = medianFloat(views) + hint = "約在中位附近" + if yourEng > 0 { + if yourEng >= medianEng*1.2 { + hint = "你高於同利基中位數" + } else if yourEng <= medianEng*0.8 { + hint = "你低於同利基中位數,可調整題材/開頭" + } + } + return len(list), medianEng, medianViews, true, hint +} + +func medianFloat(xs []float64) float64 { + if len(xs) == 0 { + return 0 + } + sort.Float64s(xs) + mid := len(xs) / 2 + if len(xs)%2 == 0 { + return (xs[mid-1] + xs[mid]) / 2 + } + return xs[mid] +} + +// --- Workspace members --- + +func (s *Service) AddWorkspaceMember(ctx context.Context, actorUID, memberUID int64, workspaceID, role string) (*domain.WorkspaceMember, error) { + w, err := s.Repo.GetWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + if w.OwnerUID != actorUID { + ok, r, _ := s.Repo.IsWorkspaceMember(ctx, workspaceID, actorUID) + if !ok || r != domain.WSRoleOwner { + return nil, domain.ErrForbidden + } + } + if memberUID <= 0 { + return nil, fmt.Errorf("%w: member_uid required", domain.ErrValidation) + } + role = strings.ToLower(strings.TrimSpace(role)) + if role == "" { + role = domain.WSRoleReviewer + } + switch role { + case domain.WSRoleEditor, domain.WSRoleReviewer, domain.WSRoleOwner: + default: + return nil, fmt.Errorf("%w: invalid role", domain.ErrValidation) + } + m := &domain.WorkspaceMember{ + ID: domain.NewID(), WorkspaceID: workspaceID, UID: memberUID, Role: role, JoinedAt: domain.NowNano(), + } + if err := s.Repo.SaveWorkspaceMember(ctx, m); err != nil { + return nil, err + } + return m, nil +} + +func (s *Service) ListWorkspaceMembers(ctx context.Context, actorUID int64, workspaceID string) ([]*domain.WorkspaceMember, error) { + ok, _, err := s.Repo.IsWorkspaceMember(ctx, workspaceID, actorUID) + if err != nil { + return nil, err + } + w, werr := s.Repo.GetWorkspace(ctx, workspaceID) + if werr != nil { + return nil, werr + } + if !ok && w.OwnerUID != actorUID { + return nil, domain.ErrForbidden + } + list, err := s.Repo.ListWorkspaceMembers(ctx, workspaceID) + if err != nil { + return nil, err + } + // ensure owner present + hasOwner := false + for _, m := range list { + if m.UID == w.OwnerUID { + hasOwner = true + break + } + } + if !hasOwner { + list = append([]*domain.WorkspaceMember{{ + ID: "owner", WorkspaceID: workspaceID, UID: w.OwnerUID, Role: domain.WSRoleOwner, JoinedAt: w.CreatedAt, + }}, list...) + } + return list, nil +} + +func (s *Service) RemoveWorkspaceMember(ctx context.Context, actorUID, targetUID int64, workspaceID string) error { + w, err := s.Repo.GetWorkspace(ctx, workspaceID) + if err != nil { + return err + } + if w.OwnerUID != actorUID { + return domain.ErrForbidden + } + if targetUID == w.OwnerUID { + return fmt.Errorf("%w: cannot remove owner", domain.ErrValidation) + } + return s.Repo.RemoveWorkspaceMember(ctx, workspaceID, targetUID) +} + +func (s *Service) UpdateWorkspaceBranding(ctx context.Context, ownerUID int64, id, footer, brandName string, hidePowered *bool) (*domain.Workspace, error) { + w, err := s.Repo.GetWorkspace(ctx, id) + if err != nil { + return nil, err + } + if w.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + if footer != "" { + w.FooterText = footer + } + if brandName != "" { + w.BrandName = brandName + } + if hidePowered != nil { + w.HidePoweredBy = *hidePowered + } + w.UpdatedAt = domain.NowNano() + if err := s.Repo.SaveWorkspace(ctx, w); err != nil { + return nil, err + } + return w, nil +} + +// EnsureReviewAllowsAutoSendP2 — owner or any reviewer/editor/member may have approved +func (s *Service) CanReviewInWorkspace(ctx context.Context, uid int64, workspaceID string) bool { + w, err := s.Repo.GetWorkspace(ctx, workspaceID) + if err != nil { + return false + } + if w.OwnerUID == uid { + return true + } + ok, role, _ := s.Repo.IsWorkspaceMember(ctx, workspaceID, uid) + return ok && (role == domain.WSRoleOwner || role == domain.WSRoleReviewer || role == domain.WSRoleEditor) +} + +// ExportReportPDF returns minimal valid PDF bytes +func ExportReportPDF(title, body string) []byte { + // Escape PDF strings + esc := func(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "(", "\\(") + s = strings.ReplaceAll(s, ")", "\\)") + return s + } + // simple multi-line text + lines := strings.Split(body, "\n") + if len(lines) > 40 { + lines = lines[:40] + } + var content strings.Builder + content.WriteString("BT /F1 14 Tf 50 780 Td (" + esc(title) + ") Tj\n") + content.WriteString("/F1 10 Tf 0 -24 Td\n") + for i, ln := range lines { + if i > 0 { + content.WriteString("0 -14 Td\n") + } + // PDF standard fonts poor for CJK — keep ASCII fallback note + safe := toPDFSafe(ln) + content.WriteString("(" + esc(safe) + ") Tj\n") + } + content.WriteString("ET") + stream := content.String() + objs := []string{ + "1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj\n", + "2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj\n", + "3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources<< /Font<< /F1 5 0 R >> >> >>endobj\n", + fmt.Sprintf("4 0 obj<< /Length %d >>stream\n%s\nendstream endobj\n", len(stream), stream), + "5 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>endobj\n", + } + var pdf strings.Builder + pdf.WriteString("%PDF-1.4\n") + offsets := []int{0} + for _, o := range objs { + offsets = append(offsets, pdf.Len()) + pdf.WriteString(o) + } + xref := pdf.Len() + pdf.WriteString(fmt.Sprintf("xref\n0 %d\n", len(offsets))) + pdf.WriteString("0000000000 65535 f \n") + for i := 1; i < len(offsets); i++ { + pdf.WriteString(fmt.Sprintf("%010d 00000 n \n", offsets[i])) + } + pdf.WriteString(fmt.Sprintf("trailer<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xref)) + return []byte(pdf.String()) +} + +func toPDFSafe(s string) string { + var b strings.Builder + for _, r := range s { + if r < 128 && r >= 32 { + b.WriteRune(r) + } else if r == '\t' { + b.WriteByte(' ') + } else { + // replace non-latin with ? + b.WriteByte('?') + } + } + out := b.String() + if strings.Trim(out, "? ") == "" && s != "" { + return "[Chinese content — open markdown export for full text]" + } + return out +} + +func Round2(v float64) float64 { + return math.Round(v*100) / 100 +} diff --git a/apps/backend/internal/module/growth/usecase/service.go b/apps/backend/internal/module/growth/usecase/service.go new file mode 100644 index 0000000..4592329 --- /dev/null +++ b/apps/backend/internal/module/growth/usecase/service.go @@ -0,0 +1,861 @@ +package usecase + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + "time" + + "apps/backend/internal/module/growth/domain" +) + +// PlanQuota returns monthly platform credits by plan id (for invite cap). +type PlanQuota func(planID string) int + +// BonusCrediter adds invite bonus points to inviter platform pool. +type BonusCrediter interface { + CreditInviteBonus(ctx context.Context, uid int64, points int, ref string) error +} + +// Notifier optional station notification. +type Notifier interface { + Notify(ctx context.Context, ownerUID int64, title, body, kind, refType, refID string) error +} + +// HealthSignals optional inputs for recompute. +type HealthSignals struct { + Sends24h int + IntervalViolations int + FailRate7d float64 // 0..1 + TokenBad bool +} + +type Service struct { + Repo domain.Repository + PlanQuota PlanQuota + Bonus BonusCrediter + Notifier Notifier + SignalProbe string // documentation: "degraded" | "partial" | "full" +} + +func New(repo domain.Repository) *Service { + return &Service{ + Repo: repo, + PlanQuota: defaultPlanQuota, + SignalProbe: "degraded", // Threads scope not fully available; OC-08 path active + } +} + +func defaultPlanQuota(planID string) int { + switch strings.ToLower(planID) { + case "starter": + return 600 + case "pro": + return 2000 + default: + return 120 + } +} + +// --- Outcomes --- + +func (s *Service) RecordPublished(ctx context.Context, ownerUID int64, sourceType, sourceID, accountID string, sentAt int64) (*domain.OutcomeEvent, error) { + if ownerUID <= 0 || sourceType == "" || sourceID == "" { + return nil, fmt.Errorf("%w: missing source", domain.ErrValidation) + } + if sentAt <= 0 { + sentAt = domain.NowNano() + } + now := domain.NowNano() + e := &domain.OutcomeEvent{ + OwnerUID: ownerUID, + SourceType: sourceType, + SourceID: sourceID, + ThreadsAccountID: accountID, + Kind: domain.KindReach, + Confidence: domain.ConfidenceConfirmed, + Status: domain.StatusObserving, + WindowEndsAt: sentAt + int64(domain.ObserveWindow), + SentAt: sentAt, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.Repo.UpsertOutcomeBySource(ctx, e); err != nil { + return nil, err + } + return s.Repo.GetOutcome(ctx, e.ID) +} + +func (s *Service) GetOutcome(ctx context.Context, ownerUID int64, id string) (*domain.OutcomeEvent, error) { + e, err := s.Repo.GetOutcome(ctx, id) + if err != nil { + return nil, err + } + if e.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + return e, nil +} + +func (s *Service) ListOutcomes(ctx context.Context, ownerUID int64, f domain.OutcomeListFilter) ([]*domain.OutcomeEvent, int64, error) { + if f.Page < 1 { + f.Page = 1 + } + if f.PageSize < 1 || f.PageSize > 50 { + f.PageSize = 20 + } + return s.Repo.ListOutcomes(ctx, ownerUID, f) +} + +func (s *Service) Summary(ctx context.Context, ownerUID int64, from, to int64) (*domain.OutcomeSummary, error) { + if to <= 0 { + to = domain.NowNano() + } + if from <= 0 { + from = to - int64(7*24*time.Hour) + } + list, _, err := s.Repo.ListOutcomes(ctx, ownerUID, domain.OutcomeListFilter{From: from, To: to, Page: 1, PageSize: 1000}) + if err != nil { + return nil, err + } + sum := &domain.OutcomeSummary{From: from, To: to} + for _, e := range list { + sum.Reach++ + if e.Kind == domain.KindReply || e.ReplyCountDelta > 0 { + sum.Conversations++ + } + if e.FollowersDelta > 0 || e.Kind == domain.KindFollow { + follows := e.FollowersDelta + if follows < 1 { + follows = 1 + } + if e.Confidence == domain.ConfidenceConfirmed { + sum.FollowsConfirmed += follows + } else { + sum.FollowsPossible += follows + } + } + if e.HasConversion || e.Kind == domain.KindConversion { + sum.Conversions++ + sum.ConversionAmount += e.ConversionAmount + } + } + return sum, nil +} + +func (s *Service) ReportConversion(ctx context.Context, ownerUID int64, id string, amount float64, note, currency string) (*domain.OutcomeEvent, error) { + e, err := s.GetOutcome(ctx, ownerUID, id) + if err != nil { + return nil, err + } + if currency == "" { + currency = "TWD" + } + now := domain.NowNano() + e.HasConversion = true + e.ConversionAmount = amount + e.ConversionNote = note + e.ConversionCurrency = currency + e.Kind = domain.KindConversion + e.Confidence = domain.ConfidenceConfirmed + e.Status = domain.StatusConfirmed + e.UpdatedAt = now + if err := s.Repo.SaveOutcome(ctx, e); err != nil { + return nil, err + } + return e, nil +} + +func (s *Service) UpdateConversion(ctx context.Context, ownerUID int64, id string, amount float64, note, currency string) (*domain.OutcomeEvent, error) { + return s.ReportConversion(ctx, ownerUID, id, amount, note, currency) +} + +func (s *Service) DeleteConversion(ctx context.Context, ownerUID int64, id string) error { + e, err := s.GetOutcome(ctx, ownerUID, id) + if err != nil { + return err + } + e.HasConversion = false + e.ConversionAmount = 0 + e.ConversionNote = "" + e.ConversionCurrency = "" + if e.ReplyCountDelta > 0 { + e.Kind = domain.KindReply + e.Status = domain.StatusConfirmed + } else if e.FollowersDelta > 0 { + e.Kind = domain.KindFollow + e.Status = domain.StatusPossible + e.Confidence = domain.ConfidencePossible + } else { + e.Kind = domain.KindReach + e.Status = domain.StatusObserving + e.Confidence = domain.ConfidenceConfirmed + } + e.UpdatedAt = domain.NowNano() + return s.Repo.SaveOutcome(ctx, e) +} + +// ApplyObserveSignals updates observing events. Missing signals are skipped (OC-08). +func (s *Service) ApplyObserveSignals(ctx context.Context, id string, replyDelta, likeDelta, followersDelta int) (*domain.OutcomeEvent, error) { + e, err := s.Repo.GetOutcome(ctx, id) + if err != nil { + return nil, err + } + now := domain.NowNano() + if e.Status != domain.StatusObserving && e.Status != domain.StatusPossible { + if now > e.WindowEndsAt && e.Status == domain.StatusObserving { + e.Status = domain.StatusExpired + e.UpdatedAt = now + _ = s.Repo.SaveOutcome(ctx, e) + } + return e, nil + } + if replyDelta > 0 { + e.ReplyCountDelta += replyDelta + e.Kind = domain.KindReply + e.Confidence = domain.ConfidenceConfirmed + e.Status = domain.StatusConfirmed + } + if likeDelta > 0 { + e.LikeCountDelta += likeDelta + if e.Kind == domain.KindReach { + e.Kind = domain.KindInteraction + e.Confidence = domain.ConfidencePossible + e.Status = domain.StatusPossible + } + } + if followersDelta > 0 { + e.FollowersDelta += followersDelta + if e.Kind != domain.KindReply && e.Kind != domain.KindConversion { + e.Kind = domain.KindFollow + e.Confidence = domain.ConfidencePossible + e.Status = domain.StatusPossible + } + } + if now > e.WindowEndsAt && e.Status == domain.StatusObserving { + e.Status = domain.StatusExpired + } + e.UpdatedAt = now + if err := s.Repo.SaveOutcome(ctx, e); err != nil { + return nil, err + } + return e, nil +} + +func (s *Service) ObserveTick(ctx context.Context, now int64) (int, error) { + if now <= 0 { + now = domain.NowNano() + } + list, err := s.Repo.ListObservingDue(ctx, now, 100) + if err != nil { + return 0, err + } + n := 0 + for _, e := range list { + if now > e.WindowEndsAt { + e.Status = domain.StatusExpired + e.UpdatedAt = now + _ = s.Repo.SaveOutcome(ctx, e) + n++ + } + // Signal fetch left degraded: no external Threads probe in default path. + } + return n, nil +} + +// --- Checkups (no usage debit — caller must not meter) --- + +func (s *Service) GenerateCheckup(ctx context.Context, ownerUID int64, timezone string, force bool) (*domain.WeeklyCheckup, error) { + now := domain.NowNano() + if force { + since := now - int64(7*24*time.Hour) + cnt, err := s.Repo.CountCheckupGenerations(ctx, ownerUID, since) + if err != nil { + return nil, err + } + if cnt >= 2 { + return nil, domain.ErrRateLimited + } + } + weekKey := weekKeyFor(timezone, time.Now().UTC()) + // Build report from outcome summary (no external AI required for MVP reliability). + sum, err := s.Summary(ctx, ownerUID, 0, 0) + if err != nil { + return nil, err + } + findings := []domain.CheckupFinding{} + if sum.Reach > 0 { + findings = append(findings, domain.CheckupFinding{ + Claim: fmt.Sprintf("近 7 日送出/標記完成 %d 則,可作為本週觸達基線", sum.Reach), + Evidence: []string{fmt.Sprintf("outcome_reach=%d", sum.Reach)}, + }) + } + if sum.Conversations > 0 { + findings = append(findings, domain.CheckupFinding{ + Claim: fmt.Sprintf("有 %d 則進入對話訊號,優先回覆這些串", sum.Conversations), + Evidence: []string{fmt.Sprintf("outcome_conversations=%d", sum.Conversations)}, + }) + } + if sum.Conversions > 0 { + findings = append(findings, domain.CheckupFinding{ + Claim: fmt.Sprintf("已回報 %d 筆成交,持續追蹤同類痛點", sum.Conversions), + Evidence: []string{fmt.Sprintf("outcome_conversions=%d amount=%.0f", sum.Conversions, sum.ConversionAmount)}, + }) + } + if len(findings) == 0 { + findings = append(findings, domain.CheckupFinding{ + Claim: "本週尚無足夠送出紀錄;建議先完成海巡外展或標記完成以累積訊號", + Evidence: []string{"outcome_reach=0"}, + }) + } + // keep only findings with evidence + var okFindings []domain.CheckupFinding + for _, f := range findings { + if len(f.Evidence) > 0 && strings.TrimSpace(f.Claim) != "" { + okFindings = append(okFindings, f) + } + } + actions := []domain.CheckupAction{ + {Title: "處理海巡待回", Reason: "把待處理命中變成可歸因送出", Deeplink: "scout"}, + {Title: "寫一則主貼", Reason: "維持帳號節奏,累積可分析貼文", Deeplink: "studio_compose"}, + {Title: "回顧今日成果", Reason: "用成本+成果判斷本週重點", Deeplink: "today"}, + } + c := &domain.WeeklyCheckup{ + ID: domain.NewID(), + OwnerUID: ownerUID, + WeekKey: weekKey, + Status: domain.CheckupReady, + Summary: fmt.Sprintf("本週觸達 %d、對話 %d、成交 %d。", sum.Reach, sum.Conversations, sum.Conversions), + Findings: okFindings, + Actions: actions, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.Repo.SaveCheckup(ctx, c); err != nil { + return nil, err + } + if s.Notifier != nil { + _ = s.Notifier.Notify(ctx, ownerUID, "每週帳號健檢已完成", c.Summary, "system", "checkup", c.ID) + } + return c, nil +} + +func weekKeyFor(tz string, now time.Time) string { + loc := time.UTC + if tz != "" { + if l, err := time.LoadLocation(tz); err == nil { + loc = l + } + } + local := now.In(loc) + // ISO week + y, w := local.ISOWeek() + return fmt.Sprintf("%d-W%02d", y, w) +} + +func (s *Service) LatestCheckup(ctx context.Context, ownerUID int64) (*domain.WeeklyCheckup, error) { + return s.Repo.LatestCheckup(ctx, ownerUID) +} + +func (s *Service) GetCheckup(ctx context.Context, ownerUID int64, id string) (*domain.WeeklyCheckup, error) { + c, err := s.Repo.GetCheckup(ctx, id) + if err != nil { + return nil, err + } + if c.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + return c, nil +} + +func (s *Service) ListCheckups(ctx context.Context, ownerUID int64, page, pageSize int) ([]*domain.WeeklyCheckup, int64, error) { + return s.Repo.ListCheckups(ctx, ownerUID, page, pageSize) +} + +// --- Account health --- + +func (s *Service) RecomputeHealth(ctx context.Context, ownerUID int64, accountID string, sig HealthSignals) (*domain.AccountHealth, error) { + score := 100 + var factors []string + if sig.Sends24h > 20 { + score -= 25 + factors = append(factors, fmt.Sprintf("24h 發送 %d 次偏高", sig.Sends24h)) + } else if sig.Sends24h > 10 { + score -= 10 + factors = append(factors, fmt.Sprintf("24h 發送 %d 次", sig.Sends24h)) + } + if sig.IntervalViolations > 0 { + d := sig.IntervalViolations * 8 + if d > 30 { + d = 30 + } + score -= d + factors = append(factors, fmt.Sprintf("間隔違規 %d 次", sig.IntervalViolations)) + } + if sig.FailRate7d >= 0.5 { + score -= 25 + factors = append(factors, fmt.Sprintf("7 日失敗率 %.0f%%", sig.FailRate7d*100)) + } else if sig.FailRate7d >= 0.25 { + score -= 12 + factors = append(factors, fmt.Sprintf("7 日失敗率 %.0f%%", sig.FailRate7d*100)) + } + if sig.TokenBad { + score -= 40 + factors = append(factors, "token 失效或錯誤") + } + if score < 0 { + score = 0 + } + level := domain.HealthNormal + advice := "操作節奏正常,可持續。" + if score < 40 { + level = domain.HealthThrottle + advice = "帳號風險偏高:已阻擋自動送出,請改用複製→標記完成,並降低密度。" + } else if score < 70 { + level = domain.HealthWarn + advice = "建議加倍發送間隔,並優先處理失敗步驟。" + } + if len(factors) == 0 { + factors = []string{"無異常訊號"} + } + h := &domain.AccountHealth{ + ThreadsAccountID: accountID, + OwnerUID: ownerUID, + Score: score, + Level: level, + Factors: factors, + Advice: advice, + ComputedAt: domain.NowNano(), + } + if err := s.Repo.SaveHealth(ctx, h); err != nil { + return nil, err + } + return h, nil +} + +func (s *Service) GetHealth(ctx context.Context, ownerUID int64, accountID string) (*domain.AccountHealth, error) { + h, err := s.Repo.GetHealth(ctx, accountID) + if err != nil { + if err == domain.ErrNotFound { + // default healthy until computed + return &domain.AccountHealth{ + ThreadsAccountID: accountID, + OwnerUID: ownerUID, + Score: 85, + Level: domain.HealthNormal, + Factors: []string{"尚未重算,預設正常"}, + Advice: "尚無足夠操作數據。", + ComputedAt: domain.NowNano(), + }, nil + } + return nil, err + } + if h.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + return h, nil +} + +func (s *Service) ListHealth(ctx context.Context, ownerUID int64) ([]*domain.AccountHealth, error) { + return s.Repo.ListHealth(ctx, ownerUID) +} + +// EnsureAutoSendAllowed returns health_warning message or ErrThrottle. +func (s *Service) EnsureAutoSendAllowed(ctx context.Context, ownerUID int64, accountID string) (warning string, err error) { + if accountID == "" { + return "", nil + } + h, err := s.GetHealth(ctx, ownerUID, accountID) + if err != nil { + return "", err + } + switch h.Level { + case domain.HealthThrottle: + return "", fmt.Errorf("%w: score=%d %s", domain.ErrThrottle, h.Score, h.Advice) + case domain.HealthWarn: + return fmt.Sprintf("健康分 %d(警告):%s", h.Score, h.Advice), nil + default: + return "", nil + } +} + +// --- Invite rewards --- + +func (s *Service) OnFirstPaidPlan(ctx context.Context, inviteeUID, parentUID int64, planID string) (*domain.InviteReward, error) { + planID = strings.ToLower(strings.TrimSpace(planID)) + if parentUID <= 0 || inviteeUID <= 0 { + return &domain.InviteReward{Status: domain.RewardSkipped}, nil + } + if planID != "starter" && planID != "pro" { + return &domain.InviteReward{Status: domain.RewardSkipped}, nil + } + has, err := s.Repo.HasInviteRewardForInvitee(ctx, inviteeUID) + if err != nil { + return nil, err + } + if has { + return &domain.InviteReward{Status: domain.RewardSkipped, InviteeUID: inviteeUID}, nil + } + points := 100 + if planID == "pro" { + points = 300 + } + quota := 120 + if s.PlanQuota != nil { + // cap based on inviter plan — unknown → free quota + quota = s.PlanQuota("free") + } + // Prefer looking up inviter plan via PlanQuota with "inviter" if set externally; + // default month cap = free 50% unless Bonus path sets better. + monthCap := quota / 2 + if monthCap < 0 { + monthCap = 0 + } + // Try inviter starter/pro quotas: use max of free half as floor; service callers + // should set PlanQuota to resolve inviter plan. + if s.PlanQuota != nil { + // convention: PlanQuota("__inviter__") not used; pass inviter plan via + // PlanQuota already closed over. Month cap = PlanQuota("current")/2. + // Here PlanQuota receives plan id; for month cap we call with "starter" max? + // Spec: 邀請人當月方案額度 50%. Caller should set: + // s.PlanQuota = func(id string) int { if id=="@inviter") return inviterQuota } + if q := s.PlanQuota("@inviter"); q > 0 { + monthCap = q / 2 + } + } + monthStart := time.Date(time.Now().UTC().Year(), time.Now().UTC().Month(), 1, 0, 0, 0, 0, time.UTC).UnixNano() + used, err := s.Repo.SumInviteRewardPointsMonth(ctx, parentUID, monthStart) + if err != nil { + return nil, err + } + r := &domain.InviteReward{ + ID: domain.NewID(), + InviterUID: parentUID, + InviteeUID: inviteeUID, + PlanID: planID, + Points: points, + CreatedAt: domain.NowNano(), + } + if used+points > monthCap && monthCap >= 0 { + r.Status = domain.RewardCapped + r.Points = 0 + _ = s.Repo.SaveInviteReward(ctx, r) + return r, nil + } + r.Status = domain.RewardCredited + if err := s.Repo.SaveInviteReward(ctx, r); err != nil { + return nil, err + } + if s.Bonus != nil && points > 0 { + _ = s.Bonus.CreditInviteBonus(ctx, parentUID, points, r.ID) + } + return r, nil +} + +func (s *Service) InviteRewardsSummary(ctx context.Context, inviterUID int64, inviterPlanID string) (total, month, cap int, recent []*domain.InviteReward, err error) { + quota := defaultPlanQuota(inviterPlanID) + if s.PlanQuota != nil { + if q := s.PlanQuota(inviterPlanID); q > 0 { + quota = q + } + } + cap = quota / 2 + monthStart := time.Date(time.Now().UTC().Year(), time.Now().UTC().Month(), 1, 0, 0, 0, 0, time.UTC).UnixNano() + total, err = s.Repo.SumInviteRewardPointsTotal(ctx, inviterUID) + if err != nil { + return + } + month, err = s.Repo.SumInviteRewardPointsMonth(ctx, inviterUID, monthStart) + if err != nil { + return + } + recent, err = s.Repo.ListInviteRewards(ctx, inviterUID, 20) + return +} + +// --- Workspaces --- + +func (s *Service) EnsureDefaultWorkspace(ctx context.Context, ownerUID int64) (*domain.Workspace, error) { + list, err := s.Repo.ListWorkspaces(ctx, ownerUID, true) + if err != nil { + return nil, err + } + for _, w := range list { + if w.IsDefault && !w.Archived { + return w, nil + } + } + for _, w := range list { + if !w.Archived { + return w, nil + } + } + now := domain.NowNano() + w := &domain.Workspace{ + ID: domain.NewID(), + OwnerUID: ownerUID, + Name: "預設工作區", + IsDefault: true, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.Repo.SaveWorkspace(ctx, w); err != nil { + return nil, err + } + _ = s.Repo.SaveMemberWSState(ctx, &domain.MemberWorkspaceState{ + OwnerUID: ownerUID, CurrentWorkspaceID: w.ID, UpdatedAt: now, + }) + return w, nil +} + +func (s *Service) ListWorkspaces(ctx context.Context, ownerUID int64) ([]*domain.Workspace, string, error) { + _, _ = s.EnsureDefaultWorkspace(ctx, ownerUID) + list, err := s.Repo.ListWorkspaces(ctx, ownerUID, false) + if err != nil { + return nil, "", err + } + cur := "" + st, err := s.Repo.GetMemberWSState(ctx, ownerUID) + if err == nil && st != nil { + cur = st.CurrentWorkspaceID + } + if cur == "" && len(list) > 0 { + cur = list[0].ID + } + return list, cur, nil +} + +func (s *Service) CreateWorkspace(ctx context.Context, ownerUID int64, name string) (*domain.Workspace, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("%w: name required", domain.ErrValidation) + } + _, _ = s.EnsureDefaultWorkspace(ctx, ownerUID) + now := domain.NowNano() + w := &domain.Workspace{ + ID: domain.NewID(), + OwnerUID: ownerUID, + Name: name, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.Repo.SaveWorkspace(ctx, w); err != nil { + return nil, err + } + return w, nil +} + +func (s *Service) UpdateWorkspace(ctx context.Context, ownerUID int64, id, name string, review *bool) (*domain.Workspace, error) { + w, err := s.Repo.GetWorkspace(ctx, id) + if err != nil { + return nil, err + } + if w.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + if name = strings.TrimSpace(name); name != "" { + w.Name = name + } + if review != nil { + w.ReviewRequired = *review + } + w.UpdatedAt = domain.NowNano() + if err := s.Repo.SaveWorkspace(ctx, w); err != nil { + return nil, err + } + return w, nil +} + +func (s *Service) ArchiveWorkspace(ctx context.Context, ownerUID int64, id string) error { + w, err := s.Repo.GetWorkspace(ctx, id) + if err != nil { + return err + } + if w.OwnerUID != ownerUID { + return domain.ErrForbidden + } + n, err := s.Repo.CountActiveWorkspaces(ctx, ownerUID) + if err != nil { + return err + } + if n <= 1 { + return fmt.Errorf("%w: must keep at least one workspace", domain.ErrValidation) + } + w.Archived = true + w.UpdatedAt = domain.NowNano() + return s.Repo.SaveWorkspace(ctx, w) +} + +func (s *Service) SwitchWorkspace(ctx context.Context, ownerUID int64, id string) error { + w, err := s.Repo.GetWorkspace(ctx, id) + if err != nil { + return err + } + if w.OwnerUID != ownerUID || w.Archived { + return domain.ErrForbidden + } + return s.Repo.SaveMemberWSState(ctx, &domain.MemberWorkspaceState{ + OwnerUID: ownerUID, CurrentWorkspaceID: id, UpdatedAt: domain.NowNano(), + }) +} + +func (s *Service) CurrentWorkspaceID(ctx context.Context, ownerUID int64) (string, error) { + list, cur, err := s.ListWorkspaces(ctx, ownerUID) + if err != nil { + return "", err + } + if cur != "" { + return cur, nil + } + if len(list) > 0 { + return list[0].ID, nil + } + return "", domain.ErrNotFound +} + +// --- Draft review --- + +func (s *Service) SubmitReview(ctx context.Context, ownerUID int64, refType, refID, workspaceID string) (*domain.DraftReview, error) { + if refType == "" || refID == "" { + return nil, fmt.Errorf("%w: ref required", domain.ErrValidation) + } + if workspaceID == "" { + var err error + workspaceID, err = s.CurrentWorkspaceID(ctx, ownerUID) + if err != nil { + return nil, err + } + } + now := domain.NowNano() + r := &domain.DraftReview{ + ID: domain.NewID(), + OwnerUID: ownerUID, + WorkspaceID: workspaceID, + RefType: refType, + RefID: refID, + Status: domain.ReviewPending, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.Repo.SaveReview(ctx, r); err != nil { + return nil, err + } + return r, nil +} + +func (s *Service) DecideReview(ctx context.Context, actorUID int64, id, status, reason string) (*domain.DraftReview, error) { + r, err := s.Repo.GetReview(ctx, id) + if err != nil { + return nil, err + } + // owner of draft OR workspace collaborator may decide + if r.OwnerUID != actorUID && !s.CanReviewInWorkspace(ctx, actorUID, r.WorkspaceID) { + return nil, domain.ErrForbidden + } + r.Status = status + r.Reason = reason + r.UpdatedAt = domain.NowNano() + if err := s.Repo.SaveReview(ctx, r); err != nil { + return nil, err + } + return r, nil +} + +func (s *Service) ListReviews(ctx context.Context, ownerUID int64, status string, page, pageSize int) ([]*domain.DraftReview, int64, error) { + return s.Repo.ListReviews(ctx, ownerUID, status, page, pageSize) +} + +// EnsureReviewAllowsAutoSend checks workspace review gate. +func (s *Service) EnsureReviewAllowsAutoSend(ctx context.Context, ownerUID int64, workspaceID, refType, refID string) error { + if workspaceID == "" { + var err error + workspaceID, err = s.CurrentWorkspaceID(ctx, ownerUID) + if err != nil { + return nil // no workspace → no gate + } + } + w, err := s.Repo.GetWorkspace(ctx, workspaceID) + if err != nil { + if err == domain.ErrNotFound { + return nil + } + return err + } + if !w.ReviewRequired { + return nil + } + r, err := s.Repo.FindReviewByRef(ctx, ownerUID, refType, refID) + if err != nil { + return domain.ErrReviewPending + } + if r.Status != domain.ReviewApproved { + return domain.ErrReviewPending + } + return nil +} + +// ExportReportMarkdown returns a simple text report (PDF engine optional; content is the contract). +func (s *Service) ExportReportMarkdown(ctx context.Context, ownerUID int64, workspaceID, yearMonth string) (filename, body string, err error) { + if yearMonth == "" { + yearMonth = time.Now().UTC().Format("2006-01") + } + w, err := s.Repo.GetWorkspace(ctx, workspaceID) + if err != nil { + return "", "", err + } + if w.OwnerUID != ownerUID { + ok, _, _ := s.Repo.IsWorkspaceMember(ctx, workspaceID, ownerUID) + if !ok { + return "", "", domain.ErrForbidden + } + } + sum, err := s.Summary(ctx, ownerUID, 0, 0) + if err != nil { + return "", "", err + } + title := w.Name + if w.BrandName != "" { + title = w.BrandName + } + filename = fmt.Sprintf("report-%s-%s.md", sanitize(title), yearMonth) + body = fmt.Sprintf("# 月報 %s · %s\n\n- 觸達: %d\n- 對話: %d\n- 成交: %d (%.0f)\n- 追蹤(可能/確定): %d / %d\n", + title, yearMonth, sum.Reach, sum.Conversations, sum.Conversions, sum.ConversionAmount, sum.FollowsPossible, sum.FollowsConfirmed) + if w.FooterText != "" { + body += "\n---\n" + w.FooterText + "\n" + } + if !w.HidePoweredBy { + body += "\n_Powered by 巡樓_\n" + } + return filename, body, nil +} + +// ExportReport returns md or pdf data URL. +func (s *Service) ExportReport(ctx context.Context, ownerUID int64, workspaceID, yearMonth, format string) (filename, contentType, dataURL string, err error) { + fn, body, err := s.ExportReportMarkdown(ctx, ownerUID, workspaceID, yearMonth) + if err != nil { + return "", "", "", err + } + format = strings.ToLower(strings.TrimSpace(format)) + if format == "pdf" { + title := strings.TrimSuffix(fn, ".md") + pdf := ExportReportPDF(title, body) + b64 := encodeB64(pdf) + return strings.TrimSuffix(fn, ".md") + ".pdf", "application/pdf", "data:application/pdf;base64," + b64, nil + } + b64 := encodeB64([]byte(body)) + return fn, "text/markdown", "data:text/markdown;base64," + b64, nil +} + +func encodeB64(b []byte) string { + return base64.StdEncoding.EncodeToString(b) +} + +func sanitize(s string) string { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, " ", "-") + if s == "" { + return "workspace" + } + return s +} diff --git a/apps/backend/internal/module/growth/usecase/service_test.go b/apps/backend/internal/module/growth/usecase/service_test.go new file mode 100644 index 0000000..c85658f --- /dev/null +++ b/apps/backend/internal/module/growth/usecase/service_test.go @@ -0,0 +1,170 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "apps/backend/internal/module/growth/domain" + "apps/backend/internal/module/growth/repository" + + "github.com/stretchr/testify/require" +) + +func TestOC_01_CreateOnPublished(t *testing.T) { + svc := New(repository.NewMemory()) + const originalSentAt int64 = 1_700_000_000_000_000_000 + e, err := svc.RecordPublished(context.Background(), 1001, domain.SourceScoutOutreach, "post1", "acc1", originalSentAt) + require.NoError(t, err) + require.Equal(t, domain.StatusObserving, e.Status) + require.Equal(t, domain.KindReach, e.Kind) + // idempotent + e2, err := svc.RecordPublished(context.Background(), 1001, domain.SourceScoutOutreach, "post1", "acc1", originalSentAt+1_000_000) + require.NoError(t, err) + require.Equal(t, e.ID, e2.ID) + require.Equal(t, originalSentAt, e2.SentAt) +} + +func TestOC_02_03_ApplySignals(t *testing.T) { + svc := New(repository.NewMemory()) + e, _ := svc.RecordPublished(context.Background(), 1, domain.SourceScoutOutreach, "p", "a", 0) + e, err := svc.ApplyObserveSignals(context.Background(), e.ID, 1, 0, 0) + require.NoError(t, err) + require.Equal(t, domain.ConfidenceConfirmed, e.Confidence) + require.Equal(t, domain.KindReply, e.Kind) + + e3, _ := svc.RecordPublished(context.Background(), 1, domain.SourceScoutOutreach, "p2", "a", 0) + e3, err = svc.ApplyObserveSignals(context.Background(), e3.ID, 0, 0, 2) + require.NoError(t, err) + require.Equal(t, domain.ConfidencePossible, e3.Confidence) + sum, err := svc.Summary(context.Background(), 1, 0, 0) + require.NoError(t, err) + require.Equal(t, 2, sum.FollowsPossible) +} + +func TestOC_04_05_Conversion(t *testing.T) { + svc := New(repository.NewMemory()) + e, _ := svc.RecordPublished(context.Background(), 1, domain.SourceScoutOutreach, "p", "a", 0) + e, err := svc.ReportConversion(context.Background(), 1, e.ID, 1000, "成交", "TWD") + require.NoError(t, err) + require.True(t, e.HasConversion) + require.Equal(t, domain.KindConversion, e.Kind) + sum, err := svc.Summary(context.Background(), 1, 0, 0) + require.NoError(t, err) + require.Equal(t, 1, sum.Conversions) + require.Equal(t, 1000.0, sum.ConversionAmount) + require.NoError(t, svc.DeleteConversion(context.Background(), 1, e.ID)) + sum, _ = svc.Summary(context.Background(), 1, 0, 0) + require.Equal(t, 0, sum.Conversions) +} + +func TestOC_08_DegradedStillWorks(t *testing.T) { + svc := New(repository.NewMemory()) + require.Equal(t, "degraded", svc.SignalProbe) + e, err := svc.RecordPublished(context.Background(), 1, domain.SourceScoutOutreach, "p", "", 0) + require.NoError(t, err) + _, err = svc.ReportConversion(context.Background(), 1, e.ID, 100, "", "") + require.NoError(t, err) + sum, err := svc.Summary(context.Background(), 1, 0, 0) + require.NoError(t, err) + require.Equal(t, 1, sum.Reach) +} + +func TestCU_01_02_03_GenerateCheckup(t *testing.T) { + svc := New(repository.NewMemory()) + _, _ = svc.RecordPublished(context.Background(), 9, domain.SourceScoutOutreach, "x", "a", 0) + c, err := svc.GenerateCheckup(context.Background(), 9, "Asia/Taipei", false) + require.NoError(t, err) + require.Equal(t, domain.CheckupReady, c.Status) + require.Len(t, c.Actions, 3) + for _, f := range c.Findings { + require.NotEmpty(t, f.Evidence) + } + // first force ok (total gens=2); third gen in 7d window blocked + _, err = svc.GenerateCheckup(context.Background(), 9, "Asia/Taipei", true) + require.NoError(t, err) + _, err = svc.GenerateCheckup(context.Background(), 9, "Asia/Taipei", true) + require.ErrorIs(t, err, domain.ErrRateLimited) +} + +func TestAH_01_03_HealthGate(t *testing.T) { + svc := New(repository.NewMemory()) + h, err := svc.RecomputeHealth(context.Background(), 1, "acc", HealthSignals{ + Sends24h: 30, IntervalViolations: 5, FailRate7d: 0.6, TokenBad: true, + }) + require.NoError(t, err) + require.Equal(t, domain.HealthThrottle, h.Level) + _, err = svc.EnsureAutoSendAllowed(context.Background(), 1, "acc") + require.ErrorIs(t, err, domain.ErrThrottle) + + h2, err := svc.RecomputeHealth(context.Background(), 1, "acc2", HealthSignals{ + Sends24h: 15, IntervalViolations: 2, FailRate7d: 0.3, + }) + require.NoError(t, err) + require.Equal(t, domain.HealthWarn, h2.Level) + warn, err := svc.EnsureAutoSendAllowed(context.Background(), 1, "acc2") + require.NoError(t, err) + require.NotEmpty(t, warn) +} + +func TestIR_01_03_InviteReward(t *testing.T) { + svc := New(repository.NewMemory()) + svc.PlanQuota = func(id string) int { + if id == "@inviter" { + return 2000 // pro inviter → month cap 1000 + } + return 120 + } + r, err := svc.OnFirstPaidPlan(context.Background(), 2, 1, "starter") + require.NoError(t, err) + require.Equal(t, domain.RewardCredited, r.Status) + require.Equal(t, 100, r.Points) + // second time skipped + r2, err := svc.OnFirstPaidPlan(context.Background(), 2, 1, "pro") + require.NoError(t, err) + require.Equal(t, domain.RewardSkipped, r2.Status) + // pro first + r3, err := svc.OnFirstPaidPlan(context.Background(), 3, 1, "pro") + require.NoError(t, err) + require.Equal(t, 300, r3.Points) +} + +func TestWS_01_02_Workspace(t *testing.T) { + svc := New(repository.NewMemory()) + list, cur, err := svc.ListWorkspaces(context.Background(), 5) + require.NoError(t, err) + require.Len(t, list, 1) + require.NotEmpty(t, cur) + w2, err := svc.CreateWorkspace(context.Background(), 5, "客戶 A") + require.NoError(t, err) + require.NoError(t, svc.SwitchWorkspace(context.Background(), 5, w2.ID)) + list, cur, err = svc.ListWorkspaces(context.Background(), 5) + require.NoError(t, err) + require.Equal(t, w2.ID, cur) + require.Len(t, list, 2) +} + +func TestWS_03_04_ReviewGate(t *testing.T) { + svc := New(repository.NewMemory()) + list, _, _ := svc.ListWorkspaces(context.Background(), 7) + ws := list[0] + on := true + _, err := svc.UpdateWorkspace(context.Background(), 7, ws.ID, "", &on) + require.NoError(t, err) + err = svc.EnsureReviewAllowsAutoSend(context.Background(), 7, ws.ID, "play", "p1") + require.True(t, errors.Is(err, domain.ErrReviewPending)) + r, err := svc.SubmitReview(context.Background(), 7, "play", "p1", ws.ID) + require.NoError(t, err) + _, err = svc.DecideReview(context.Background(), 7, r.ID, domain.ReviewApproved, "") + require.NoError(t, err) + require.NoError(t, svc.EnsureReviewAllowsAutoSend(context.Background(), 7, ws.ID, "play", "p1")) +} + +func TestWS_05_ExportReport(t *testing.T) { + svc := New(repository.NewMemory()) + list, _, _ := svc.ListWorkspaces(context.Background(), 8) + fn, body, err := svc.ExportReportMarkdown(context.Background(), 8, list[0].ID, "2026-07") + require.NoError(t, err) + require.Contains(t, fn, "2026-07") + require.Contains(t, body, "月報") +} diff --git a/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go b/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go index 9e2161d..feeb739 100644 --- a/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go +++ b/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go @@ -38,6 +38,15 @@ func (fakePersonaSource) ResolvePersona(context.Context, int64, string) (*usecas }, nil } +type topicSearchStub struct{} + +func (topicSearchStub) Search(context.Context, string, string, int) ([]search.Hit, error) { + return []search.Hit{{ + Title: "Threads 熱議:夏日防曬", + Snippet: "#夏日防曬 最近討論增加", + }}, nil +} + func setupInspireUID(svc *usecase.Service, uid int64) { _ = svc.Usage.Repo.SavePrefs(context.Background(), &usageDomain.MemberPrefs{ UID: uid, PlanID: usageDomain.PlanPro, Unlimited: true, UpdatedAt: domain.NowNano(), @@ -93,10 +102,41 @@ func TestIN_02_RefreshTrends(t *testing.T) { svc := newInspire() uid := int64(5_001_002) setupInspireUID(svc, uid) + svc.Search = topicSearchStub{} + svc.ResolveKey = func(context.Context, int64, string) (string, string, error) { + return usageDomain.KeyModePlatform, "test-search-key", nil + } _, _ = svc.ListTrends(context.Background(), uid) list, err := svc.RefreshTrends(context.Background(), uid) require.NoError(t, err) require.NotEmpty(t, list) + require.Equal(t, "web", list[0].SourceLabel) + + // 相同搜尋結果不能再次冒充更新成功。 + _, err = svc.RefreshTrends(context.Background(), uid) + require.Error(t, err) +} + +func TestIN_02_RefreshFailureKeepsCacheAndDoesNotCharge(t *testing.T) { + svc := newInspire() + uid := int64(5_001_012) + setupInspireUID(svc, uid) + svc.Search = &search.FakeClient{Fail: true} + svc.ResolveKey = func(context.Context, int64, string) (string, string, error) { + return usageDomain.KeyModePlatform, "test-search-key", nil + } + before, err := svc.ListTrends(context.Background(), uid) + require.NoError(t, err) + + _, err = svc.RefreshTrends(context.Background(), uid) + require.Error(t, err) + + after, err := svc.ListTrends(context.Background(), uid) + require.NoError(t, err) + require.Equal(t, before[0].ID, after[0].ID) + counter, err := svc.Usage.Repo.GetMonthlyCounter(context.Background(), uid, usageDomain.CurrentMonthKey()) + require.ErrorIs(t, err, usageDomain.ErrNotFound) + require.Nil(t, counter) } func TestIN_03_ElementCRUD(t *testing.T) { diff --git a/apps/backend/internal/module/inspire/usecase/service.go b/apps/backend/internal/module/inspire/usecase/service.go index fa0dfbe..22565ab 100644 --- a/apps/backend/internal/module/inspire/usecase/service.go +++ b/apps/backend/internal/module/inspire/usecase/service.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "strings" + "time" "unicode/utf8" "apps/backend/internal/module/ai" @@ -51,22 +52,15 @@ func (s *Service) ListTrends(ctx context.Context, ownerUID int64) ([]*domain.Tre return list, nil } -// RefreshTrends — 手動「找靈感」:先扣 web_search 點數,再網搜抽話題。 -// 扣點失敗直接回錯(不 soft ignore);搜尋失敗可回種子但不退點(已消耗配額嘗試)。 +// RefreshTrends — 手動「找靈感」:取得真實結果後才扣點並覆蓋快取。 +// 搜尋失敗時保留舊內容;不可用示意種子冒充本次刷新成功。 func (s *Service) RefreshTrends(ctx context.Context, ownerUID int64) ([]*domain.TrendItem, error) { - if err := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "找靈感話題", "inspire.refreshTrends"); err != nil { - return nil, err - } list := s.tryLiveTrends(ctx, ownerUID) if len(list) == 0 { - list = defaultTrends(ownerUID) - for i := range list { - list[i].Heat = i + 1 - list[i].ObservedAt = domain.NowNano() - list[i].ID = "tr_" + uuid.NewString()[:10] - list[i].SourceLabel = "seed" - list[i].Summary = "網搜未取到可用話題,以下為示意" - } + return nil, fmt.Errorf("找不到新的可用話題,已保留原本內容") + } + if err := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "找靈感話題", "inspire.refreshTrends"); err != nil { + return nil, err } if err := s.Repo.SaveTrends(ctx, ownerUID, list); err != nil { return nil, err @@ -91,10 +85,30 @@ func (s *Service) tryLiveTrends(ctx context.Context, ownerUID int64) []*domain.T } // 計費在 RefreshTrends 已做;此處只搜尋 + previous := map[string]struct{}{} + var excluded []string + if cached, err := s.Repo.ListTrends(ctx, ownerUID); err == nil { + for _, item := range cached { + if item == nil { + continue + } + label := strings.TrimSpace(item.Label) + if label == "" { + continue + } + previous[strings.ToLower(label)] = struct{}{} + if len(excluded) < 6 { + excluded = append(excluded, label) + } + } + } + nowTW := time.Now().In(time.FixedZone("Asia/Taipei", 8*60*60)) + freshness := nowTW.Format("2006-01-02 15:04") + excludeHint := strings.Join(excluded, "、") queries := []string{ - "本週 Threads 紅什麼 台灣", - "Threads 熱議話題 列表 台灣 本週", - "Threads 夯 話題 二砂糖 OR 泡麵 OR 颱風 台灣", + fmt.Sprintf("%s 台灣 Threads 最近正在討論的新話題,不要重複:%s", freshness, excludeHint), + fmt.Sprintf("%s Threads 台灣最新貼文熱門關鍵字與生活討論,不要:%s", freshness, excludeHint), + fmt.Sprintf("%s 台灣社群近 24 小時新話題 Threads 趨勢,不要重複:%s", freshness, excludeHint), } seen := map[string]struct{}{} now := domain.NowNano() @@ -113,6 +127,9 @@ func (s *Service) tryLiveTrends(ctx context.Context, ownerUID int64) []*domain.T if keyNorm == "" { continue } + if _, old := previous[keyNorm]; old { + continue + } if _, ok := seen[keyNorm]; ok { continue } @@ -131,7 +148,7 @@ func (s *Service) tryLiveTrends(ctx context.Context, ownerUID int64) []*domain.T Kind: "threads_tag", Label: label, Summary: truncate(summary, 120), - Heat: rank, // 列表序位,前端當 #rank 用 + Heat: 101 - rank, // 越前面的搜尋結果熱度越高,符合前端倒序排列 Keywords: []string{label}, Samples: []string{truncate(summary, 80)}, SourceLabel: "web", @@ -1505,11 +1522,11 @@ func defaultTrends(ownerUID int64) []*domain.TrendItem { now := domain.NowNano() // 僅 live 失敗時的後備;SourceLabel=seed,UI 應標示「示意」而非 Threads 即時榜 return []*domain.TrendItem{ - {ID: "tr_seed_1", OwnerUID: ownerUID, Kind: "threads_tag", Label: "敏感肌換季", Summary: "示意話題(網搜未就緒)", Heat: 1, + {ID: "tr_seed_1", OwnerUID: ownerUID, Kind: "threads_tag", Label: "敏感肌換季", Summary: "示意話題(網搜未就緒)", Heat: 100, Keywords: []string{"敏感肌"}, Samples: []string{"換季又紅了…"}, SourceLabel: "seed", ObservedAt: now}, - {ID: "tr_seed_2", OwnerUID: ownerUID, Kind: "web_keyword", Label: "遠端上班", Summary: "示意話題(網搜未就緒)", Heat: 2, + {ID: "tr_seed_2", OwnerUID: ownerUID, Kind: "web_keyword", Label: "遠端上班", Summary: "示意話題(網搜未就緒)", Heat: 90, Keywords: []string{"遠端"}, Samples: []string{"居家辦公第三年"}, SourceLabel: "seed", ObservedAt: now}, - {ID: "tr_seed_3", OwnerUID: ownerUID, Kind: "news", Label: "無香洗髮", Summary: "示意話題(網搜未就緒)", Heat: 3, + {ID: "tr_seed_3", OwnerUID: ownerUID, Kind: "news", Label: "無香洗髮", Summary: "示意話題(網搜未就緒)", Heat: 80, Keywords: []string{"無香"}, Samples: []string{"終於找到不刺鼻的"}, SourceLabel: "seed", ObservedAt: now}, } } diff --git a/apps/backend/internal/module/member/usecase/m1_t119_test.go b/apps/backend/internal/module/member/usecase/m1_t119_test.go index d2872a1..7666868 100644 --- a/apps/backend/internal/module/member/usecase/m1_t119_test.go +++ b/apps/backend/internal/module/member/usecase/m1_t119_test.go @@ -309,7 +309,7 @@ func TestAP_07_ChangePasswordSuccess(t *testing.T) { func TestAP_08_AvatarURLSetAndClear(t *testing.T) { store, svc := newM1Svc() m, _ := seedMember(t, store, svc, "ap08@test.local") - url := "https://threads-tool-dev.30cm.net/haixun-assets/avatar/1.png" + url := "https://threads-tool.30cm.net/haixun-assets/avatar/1.png" out, err := svc.UpdateUserInfo(context.Background(), m.UID, &domain.UpdateUserInfoPatch{AvatarURL: &url}) require.NoError(t, err) require.Equal(t, url, out.AvatarURL) diff --git a/apps/backend/internal/module/scout/domain/domain.go b/apps/backend/internal/module/scout/domain/domain.go index 14db36e..ac280c1 100644 --- a/apps/backend/internal/module/scout/domain/domain.go +++ b/apps/backend/internal/module/scout/domain/domain.go @@ -46,8 +46,13 @@ type Brand struct { Brief string `bson:"brief" json:"brief"` TargetAudience string `bson:"target_audience,omitempty" json:"target_audience,omitempty"` Goals string `bson:"goals,omitempty" json:"goals,omitempty"` - CreatedAt int64 `bson:"created_at" json:"created_at"` - UpdatedAt int64 `bson:"updated_at" json:"updated_at"` + // Learning — growth-loop 資產複利 + LearningSummary string `bson:"learning_summary,omitempty" json:"learning_summary,omitempty"` + LearningVersion int `bson:"learning_version,omitempty" json:"learning_version,omitempty"` + LearnedFromPostsCount int `bson:"learned_from_posts_count,omitempty" json:"learned_from_posts_count,omitempty"` + LastLearnedAt int64 `bson:"last_learned_at,omitempty" json:"last_learned_at,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` } type Product struct { diff --git a/apps/backend/internal/module/scout/usecase/service.go b/apps/backend/internal/module/scout/usecase/service.go index eb3e732..d6bd45e 100644 --- a/apps/backend/internal/module/scout/usecase/service.go +++ b/apps/backend/internal/module/scout/usecase/service.go @@ -25,6 +25,9 @@ type ReplyQueue interface { QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (outboxID string, err error) } +// OutreachPublishedHook fires after mark-published / successful outreach send path. +type OutreachPublishedHook func(ctx context.Context, ownerUID int64, postID, accountID string) + type Service struct { Repo domain.Repository Settings SettingsReader @@ -35,6 +38,8 @@ type Service struct { Provider ThreadSearchProvider Crawler ChromeCrawlerProvider SessionSecret string + // OnOutreachPublished optional growth-loop outcome hook. + OnOutreachPublished OutreachPublishedHook } func New(repo domain.Repository) *Service { @@ -394,6 +399,20 @@ func (s *Service) MarkPublished(ctx context.Context, ownerUID int64, postID stri if err := s.Repo.SavePost(ctx, p); err != nil { return nil, err } + // growth-loop: brand learning bump when brand known + if p.BrandID != "" { + if b, berr := s.Repo.GetBrand(ctx, p.BrandID); berr == nil && b != nil && b.OwnerUID == ownerUID { + b.LearningVersion++ + b.LearnedFromPostsCount++ + b.LastLearnedAt = domain.NowNano() + b.LearningSummary = fmt.Sprintf("海巡外展累積 %d 則 · 知識 v%d", b.LearnedFromPostsCount, b.LearningVersion) + b.UpdatedAt = domain.NowNano() + _ = s.Repo.SaveBrand(ctx, b) + } + } + if s.OnOutreachPublished != nil { + s.OnOutreachPublished(ctx, ownerUID, postID, "") + } return p, nil } diff --git a/apps/backend/internal/module/studio/domain/domain.go b/apps/backend/internal/module/studio/domain/domain.go index 25a6fe1..75fe7f2 100644 --- a/apps/backend/internal/module/studio/domain/domain.go +++ b/apps/backend/internal/module/studio/domain/domain.go @@ -66,8 +66,13 @@ type Persona struct { Guard PersonaGuard `bson:"guard" json:"guard"` Voice string `bson:"voice,omitempty" json:"voice,omitempty"` Notes string `bson:"notes,omitempty" json:"notes,omitempty"` - CreatedAt int64 `bson:"created_at" json:"created_at"` - UpdatedAt int64 `bson:"updated_at" json:"updated_at"` + // Learning — 資產複利可見(growth-loop) + LearningSummary string `bson:"learning_summary,omitempty" json:"learning_summary,omitempty"` + LearningVersion int `bson:"learning_version,omitempty" json:"learning_version,omitempty"` + LearnedFromPostsCount int `bson:"learned_from_posts_count,omitempty" json:"learned_from_posts_count,omitempty"` + LastLearnedAt int64 `bson:"last_learned_at,omitempty" json:"last_learned_at,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` } type PersonaStyle struct { diff --git a/apps/backend/internal/module/studio/usecase/service.go b/apps/backend/internal/module/studio/usecase/service.go index 4e4bb5d..82144b6 100644 --- a/apps/backend/internal/module/studio/usecase/service.go +++ b/apps/backend/internal/module/studio/usecase/service.go @@ -112,6 +112,8 @@ type Service struct { OutboxWorkerID string // OutboxLeaseDuration is configurable for focused lease-renewal tests. OutboxLeaseDuration time.Duration + // OnStepPublished optional growth-loop outcome hook after real publish. + OnStepPublished func(ctx context.Context, ownerUID int64, bundleID, stepID, accountID string) } func New(repo domain.Repository, transport publish.Transport) *Service { @@ -556,6 +558,15 @@ func (s *Service) finishPersonaStyleAnalysis( return nil } p.Notes = fmt.Sprintf("LLM 分析 · %s / %s · 樣本 %d 則", provider, model, len(samples)) + // growth-loop: 資產複利可見 + p.LearningVersion++ + p.LearnedFromPostsCount = len(samples) + p.LastLearnedAt = domain.NowNano() + if p.LearningSummary == "" { + p.LearningSummary = fmt.Sprintf("已從 %d 則樣本學到語氣/結構指紋(v%d)", len(samples), p.LearningVersion) + } else { + p.LearningSummary = fmt.Sprintf("更新語言指紋 v%d · 樣本 %d 則", p.LearningVersion, len(samples)) + } p.UpdatedAt = domain.NowNano() return nil } @@ -1311,6 +1322,9 @@ func (s *Service) processBundle(ctx context.Context, b *domain.OutboxBundle, now return count, err } s.cleanupEphemeralImages(ctx, images) + if s.OnStepPublished != nil { + s.OnStepPublished(ctx, b.OwnerUID, b.ID, result.ID, result.AccountID) + } count++ } fresh, getErr := s.Repo.GetOutbox(ctx, b.ID) diff --git a/apps/backend/internal/module/usage/domain/usage.go b/apps/backend/internal/module/usage/domain/usage.go index 96dd35e..8710a66 100644 --- a/apps/backend/internal/module/usage/domain/usage.go +++ b/apps/backend/internal/module/usage/domain/usage.go @@ -69,9 +69,14 @@ type MemberPrefs struct { CancelAtPeriodEnd bool `bson:"cancel_at_period_end" json:"cancel_at_period_end"` BillingEventID string `bson:"billing_event_id,omitempty" json:"-"` BillingEventCreatedAt int64 `bson:"billing_event_created_at,omitempty" json:"-"` - UpdatedAt int64 `bson:"updated_at" json:"updated_at"` + // InviteBonusCredits — 邀請贈點(入平台額度池,與消耗分開顯示) + InviteBonusCredits int `bson:"invite_bonus_credits,omitempty" json:"invite_bonus_credits,omitempty"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` } +const MeterInviteBonus = "invite_bonus" +const KeyModeBonus = "bonus" + type Purchase struct { ID string `bson:"_id" json:"id"` UID int64 `bson:"uid" json:"uid"` diff --git a/apps/backend/internal/module/usage/usecase/service.go b/apps/backend/internal/module/usage/usecase/service.go index c41600f..985bc39 100644 --- a/apps/backend/internal/module/usage/usecase/service.go +++ b/apps/backend/internal/module/usage/usecase/service.go @@ -107,7 +107,10 @@ func (s *Service) reservePlatform(ctx context.Context, uid int64, meter string) return err } plan := domain.ResolvePlan(prefs.PlanID) - totalLimit, meterLimit := plan.MonthlyCredits, -1 + totalLimit, meterLimit := plan.MonthlyCredits+prefs.InviteBonusCredits, -1 + if totalLimit < plan.MonthlyCredits { + totalLimit = plan.MonthlyCredits + } if capN, ok := plan.SoftCaps[meter]; ok && capN > 0 { meterLimit = capN } @@ -187,7 +190,12 @@ func (s *Service) GetSummary(ctx context.Context, uid int64, monthKey string) (* } else if counterErr != domain.ErrNotFound { return nil, counterErr } - remaining := plan.MonthlyCredits - platUsed + bonus := prefs.InviteBonusCredits + if bonus < 0 { + bonus = 0 + } + totalPool := plan.MonthlyCredits + bonus + remaining := totalPool - platUsed if remaining < 0 { remaining = 0 } @@ -195,15 +203,38 @@ func (s *Service) GetSummary(ctx context.Context, uid int64, monthKey string) (* return &domain.MonthSummary{ MonthKey: monthKey, UID: uid, PlanID: plan.ID, Unlimited: prefs.Unlimited, Platform: domain.PlatformBlock{ - CreditsUsed: platUsed, CreditsRemaining: remaining, CreditsTotal: plan.MonthlyCredits, + CreditsUsed: platUsed, CreditsRemaining: remaining, CreditsTotal: totalPool, CallCount: platCalls, ByMeter: platBy, }, Byok: domain.ByokBlock{CallCount: byokCalls, ByMeter: byokBy}, - // TotalCredits = 方案配給(legacy 欄位名);已用請看 platform.credits_used - TotalCredits: plan.MonthlyCredits, RemainingCredits: remaining, + // TotalCredits = 方案配給 + 邀請贈點;已用請看 platform.credits_used + TotalCredits: totalPool, RemainingCredits: remaining, }, nil } +// CreditInviteBonus — 邀請贈點入平台額度池;另記 bonus event(不進 platform 消耗計數) +func (s *Service) CreditInviteBonus(ctx context.Context, uid int64, points int, ref string) error { + if uid <= 0 || points <= 0 { + return nil + } + prefs, err := s.ensurePrefs(ctx, uid) + if err != nil { + return err + } + prefs.InviteBonusCredits += points + prefs.UpdatedAt = domain.NowNano() + if err := s.Repo.SavePrefs(ctx, prefs); err != nil { + return err + } + now := domain.NowNano() + _ = s.Repo.InsertEvent(ctx, &domain.Event{ + ID: uuid.NewString(), UID: uid, Meter: domain.MeterInviteBonus, Credits: 0, + KeyMode: domain.KeyModeBonus, Label: fmt.Sprintf("邀請贈點 +%d", points), + Source: "invite_reward:" + ref, MonthKey: domain.MonthKey(now), CreatedAt: now, + }) + return nil +} + func (s *Service) ListEvents(ctx context.Context, uid int64, monthKey, keyMode string, limit int) ([]*domain.Event, error) { if limit <= 0 { limit = 100 diff --git a/apps/backend/internal/module/usage/usecase/service_test.go b/apps/backend/internal/module/usage/usecase/service_test.go index f8832f1..41b0e5b 100644 --- a/apps/backend/internal/module/usage/usecase/service_test.go +++ b/apps/backend/internal/module/usage/usecase/service_test.go @@ -294,3 +294,13 @@ func TestUSG_14_MemberCannotSetOthersPrefs(t *testing.T) { _, err := svc.SetPrefs(context.Background(), 1_000_001, 1_000_002, false, domain.PlanPro, nil) require.ErrorIs(t, err, domain.ErrForbidden) } + +func TestCreditInviteBonus_AddsToPool(t *testing.T) { + svc := newUsage(42, domain.KeyModePlatform) + require.NoError(t, svc.CreditInviteBonus(context.Background(), 42, 100, "r1")) + sum, err := svc.GetSummary(context.Background(), 42, "") + require.NoError(t, err) + // free 120 + 100 bonus + require.Equal(t, 220, sum.TotalCredits) + require.Equal(t, 220, sum.RemainingCredits) +} diff --git a/apps/backend/internal/response/response.go b/apps/backend/internal/response/response.go index 26ff844..2faa9f5 100644 --- a/apps/backend/internal/response/response.go +++ b/apps/backend/internal/response/response.go @@ -9,6 +9,7 @@ import ( "apps/backend/internal/domain" appnotifDomain "apps/backend/internal/module/appnotif/domain" billingDomain "apps/backend/internal/module/billing" + growthDomain "apps/backend/internal/module/growth/domain" inspireDomain "apps/backend/internal/module/inspire/domain" jobDomain "apps/backend/internal/module/job/domain" memberDomain "apps/backend/internal/module/member/domain" @@ -195,6 +196,18 @@ func mapError(err error) (int, Envelope) { return http.StatusBadRequest, Envelope{Code: 400061, Message: "crawler session required when dev_mode enabled"} case errors.Is(err, scoutDomain.ErrHasProducts): return http.StatusConflict, Envelope{Code: 409030, Message: "brand has products; remove products first"} + case errors.Is(err, growthDomain.ErrNotFound): + return http.StatusNotFound, Envelope{Code: 404001, Message: "not found"} + case errors.Is(err, growthDomain.ErrForbidden): + return http.StatusForbidden, Envelope{Code: 403003, Message: "forbidden"} + case errors.Is(err, growthDomain.ErrValidation): + return http.StatusBadRequest, Envelope{Code: 400090, Message: cleanBizMessage(err.Error())} + case errors.Is(err, growthDomain.ErrThrottle): + return http.StatusConflict, Envelope{Code: 409040, Message: err.Error()} + case errors.Is(err, growthDomain.ErrReviewPending): + return http.StatusConflict, Envelope{Code: 409041, Message: "draft review required before auto send"} + case errors.Is(err, growthDomain.ErrRateLimited): + return http.StatusTooManyRequests, Envelope{Code: 429020, Message: "checkup regenerate limit (max 2 per 7 days)"} default: return http.StatusInternalServerError, Envelope{Code: 500000, Message: "internal server error"} } diff --git a/apps/backend/internal/svc/service_context.go b/apps/backend/internal/svc/service_context.go index e45a5b2..81c55b5 100644 --- a/apps/backend/internal/svc/service_context.go +++ b/apps/backend/internal/svc/service_context.go @@ -18,6 +18,8 @@ import ( fsDomain "apps/backend/internal/module/filestorage/domain" "apps/backend/internal/module/filestorage/noop" "apps/backend/internal/module/filestorage/s3store" + growthRepo "apps/backend/internal/module/growth/repository" + growthUC "apps/backend/internal/module/growth/usecase" inspireRepo "apps/backend/internal/module/inspire/repository" inspireUC "apps/backend/internal/module/inspire/usecase" jobRepo "apps/backend/internal/module/job/repository" @@ -77,6 +79,7 @@ type ServiceContext struct { PublishTransport studioPublish.Transport Inspire *inspireUC.Service Scout *scoutUC.Service + Growth *growthUC.Service ExtensionZipPath string } @@ -193,6 +196,18 @@ func NewServiceContext(c config.Config) *ServiceContext { inspireSvc.Personas = &inspirePersonaBridge{Studio: studioSvc} inspireSvc.Brands = &inspireBrandBridge{Scout: scoutSvc} + growthSvc := growthUC.New(growthRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)) + growthSvc.Notifier = &growthNotifBridge{App: appN} + growthSvc.Bonus = usageSvc + // Scout mark-published → outcome hook + scoutSvc.OnOutreachPublished = func(ctx context.Context, ownerUID int64, postID, accountID string) { + _, _ = growthSvc.RecordPublished(ctx, ownerUID, "scout_outreach", postID, accountID, 0) + } + // Outbox step published → outcome + studioSvc.OnStepPublished = func(ctx context.Context, ownerUID int64, bundleID, stepID, accountID string) { + _, _ = growthSvc.RecordPublished(ctx, ownerUID, "outbox_step", bundleID+":"+stepID, accountID, 0) + } + zipPath := findExtensionZip() return &ServiceContext{ @@ -216,6 +231,7 @@ func NewServiceContext(c config.Config) *ServiceContext { PublishTransport: pub, Inspire: inspireSvc, Scout: scoutSvc, + Growth: growthSvc, ExtensionZipPath: zipPath, AuthJWT: middleware.NewAuthJWTMiddleware(issuer, repo).Handle, AdminAuth: middleware.NewAdminAuthMiddleware().Handle, @@ -645,3 +661,15 @@ func (b *inspireBrandBridge) ListCatalog(ctx context.Context, ownerUID int64) ([ } return out, nil } + +type growthNotifBridge struct { + App *appnotifUC.Service +} + +func (b *growthNotifBridge) Notify(ctx context.Context, ownerUID int64, title, body, kind, refType, refID string) error { + if b == nil || b.App == nil { + return nil + } + // Reuse job-state channel for system notes via NotifyJobState with synthetic id. + return b.App.NotifyJobState(ctx, ownerUID, "growth:"+refType+":"+refID, "growth", "succeeded", title+": "+body, 100) +} diff --git a/apps/backend/internal/types/m4_convert.go b/apps/backend/internal/types/m4_convert.go index f349d18..9f44dfe 100644 --- a/apps/backend/internal/types/m4_convert.go +++ b/apps/backend/internal/types/m4_convert.go @@ -28,7 +28,10 @@ func PersonaFromDomain(p *domain.Persona) PersonaPublic { Guard: PersonaGuardPublic{ Avoid: p.Guard.Avoid, MaxChars: p.Guard.MaxChars, BanAiTone: p.Guard.BanAiTone, }, - Voice: p.Voice, Notes: p.Notes, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, + Voice: p.Voice, Notes: p.Notes, + LearningSummary: p.LearningSummary, LearningVersion: p.LearningVersion, + LearnedFromPostsCount: p.LearnedFromPostsCount, LastLearnedAt: p.LastLearnedAt, + CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, } } diff --git a/apps/backend/internal/types/m5_convert.go b/apps/backend/internal/types/m5_convert.go index c7cc695..84e439e 100644 --- a/apps/backend/internal/types/m5_convert.go +++ b/apps/backend/internal/types/m5_convert.go @@ -67,6 +67,8 @@ func BrandFromDomain(b *scoutDomain.Brand) BrandPublic { return BrandPublic{ Id: b.ID, DisplayName: b.DisplayName, Brief: b.Brief, TargetAudience: b.TargetAudience, Goals: b.Goals, + LearningSummary: b.LearningSummary, LearningVersion: b.LearningVersion, + LearnedFromPostsCount: b.LearnedFromPostsCount, LastLearnedAt: b.LastLearnedAt, } } diff --git a/apps/backend/internal/types/types.go b/apps/backend/internal/types/types.go index a017742..b84afd3 100644 --- a/apps/backend/internal/types/types.go +++ b/apps/backend/internal/types/types.go @@ -15,6 +15,26 @@ type AICompleteReq struct { Meter string `json:"meter,optional"` // default ai_copy } +type AccountHealthListData struct { + List []AccountHealthPublic `json:"list"` +} + +type AccountHealthPublic struct { + ThreadsAccountId string `json:"threads_account_id"` + Score int `json:"score"` + Level string `json:"level"` + Factors []string `json:"factors"` + Advice string `json:"advice"` + ComputedAt int64 `json:"computed_at"` +} + +type AddWorkspaceMemberReq struct { + WorkspaceId string `path:"id"` + MemberUid int64 `json:"member_uid,optional"` + Email string `json:"email,optional"` + Role string `json:"role,optional"` // editor | reviewer | owner +} + type AdminCreateMemberData struct { User MemberPublic `json:"user"` TemporaryPassword string `json:"temporary_password"` @@ -216,6 +236,23 @@ type AuthVerifyEmailReq struct { Code string `json:"code"` } +type BenchmarkData struct { + Available bool `json:"available"` + SampleSize int `json:"sample_size"` + Niche string `json:"niche"` + MedianEngRate float64 `json:"median_eng_rate"` + MedianViews float64 `json:"median_views"` + YourEngRate float64 `json:"your_eng_rate,optional"` + YourViews float64 `json:"your_views,optional"` + PercentileHint string `json:"percentile_hint,optional"` + Message string `json:"message,optional"` +} + +type BenchmarkReq struct { + Niche string `form:"niche,optional"` + AccountId string `form:"account_id,optional"` +} + type BillingCheckoutCreateReq struct { PlanId string `json:"plan_id"` RequestId string `json:"request_id"` @@ -265,11 +302,15 @@ type BrandListData struct { } type BrandPublic struct { - Id string `json:"id"` - DisplayName string `json:"display_name"` - Brief string `json:"brief"` - TargetAudience string `json:"target_audience,optional"` - Goals string `json:"goals,optional"` + Id string `json:"id"` + DisplayName string `json:"display_name"` + Brief string `json:"brief"` + TargetAudience string `json:"target_audience,optional"` + Goals string `json:"goals,optional"` + LearningSummary string `json:"learning_summary,optional"` + LearningVersion int `json:"learning_version,optional"` + LearnedFromPostsCount int `json:"learned_from_posts_count,optional"` + LastLearnedAt int64 `json:"last_learned_at,optional"` } type BrandSaveReq struct { @@ -284,6 +325,22 @@ type BrandSetActiveReq struct { Id string `json:"id"` } +type CheckupActionPublic struct { + Title string `json:"title"` + Reason string `json:"reason"` + Deeplink string `json:"deeplink"` +} + +type CheckupFindingPublic struct { + Claim string `json:"claim"` + Evidence []string `json:"evidence"` +} + +type CheckupListData struct { + List []WeeklyCheckupPublic `json:"list"` + Pagination Pagination `json:"pagination"` +} + type ComposeMimicData struct { Text string `json:"text,optional"` JobId string `json:"job_id,optional"` @@ -324,9 +381,53 @@ type ComposeViralReq struct { Text string `json:"text"` } +type CreateUtmLinkReq struct { + DestinationUrl string `json:"destination_url"` + OutcomeId string `json:"outcome_id,optional"` + Label string `json:"label,optional"` +} + +type CreateWorkspaceReq struct { + Name string `json:"name"` +} + +type DeleteConversionReq struct { + Id string `path:"id"` +} + +type DraftReviewListData struct { + List []DraftReviewPublic `json:"list"` + Pagination Pagination `json:"pagination"` +} + +type DraftReviewPublic struct { + Id string `json:"id"` + RefType string `json:"ref_type"` + RefId string `json:"ref_id"` + WorkspaceId string `json:"workspace_id"` + Status string `json:"status"` + Reason string `json:"reason,optional"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + type Empty struct { } +type ExportReportData struct { + Url string `json:"url"` + ExpiresAt int64 `json:"expires_at,optional"` + Filename string `json:"filename"` + ContentType string `json:"content_type,optional"` + Format string `json:"format,optional"` +} + +type ExportReportReq struct { + WorkspaceId string `form:"workspace_id"` + YearMonth string `form:"year_month"` // YYYY-MM + Format string `form:"format,optional"` // md | pdf +} + type ExternalTargetPublic struct { Url string `json:"url"` RawUrl string `json:"raw_url,optional"` @@ -347,10 +448,22 @@ type GenerateImageReq struct { Prompt string `json:"prompt"` } +type GetAccountHealthReq struct { + AccountId string `path:"accountId"` +} + type GetAiReq struct { Provider string `form:"provider,optional"` } +type GetCheckupReq struct { + Id string `path:"id"` +} + +type GetOutcomeReq struct { + Id string `path:"id"` +} + type HealthData struct { Status string `json:"status"` Service string `json:"service"` @@ -363,6 +476,12 @@ type IdentityPublic struct { CreatedAt int64 `json:"created_at,optional"` } +type ImportPlaybookData struct { + Id string `json:"id"` + Copied bool `json:"copied"` + Message string `json:"message"` +} + type ImportProductData struct { Label string `json:"label"` ProductContext string `json:"product_context"` @@ -391,6 +510,43 @@ type ImportThreadsAccountSessionReq struct { StorageState string `json:"storageState"` } +type InsightsMonthBucket struct { + MonthKey string `json:"month_key"` + Posts int `json:"posts"` + Views int `json:"views"` + Likes int `json:"likes"` + Replies int `json:"replies"` + AvgEngRate float64 `json:"avg_eng_rate"` +} + +type InsightsPostPublic struct { + Id string `json:"id"` + TextPreview string `json:"text_preview"` + PublishedAt int64 `json:"published_at"` + ViewCount int `json:"view_count"` + LikeCount int `json:"like_count"` + ReplyCount int `json:"reply_count"` + EngRate float64 `json:"eng_rate"` +} + +type InsightsSummaryData struct { + AccountId string `json:"account_id,optional"` + PostCount int `json:"post_count"` + TotalViews int `json:"total_views"` + TotalLikes int `json:"total_likes"` + TotalReplies int `json:"total_replies"` + AvgEngRate float64 `json:"avg_eng_rate"` + TopPosts []InsightsPostPublic `json:"top_posts"` + MonthBuckets []InsightsMonthBucket `json:"month_buckets"` + Conclusion string `json:"conclusion"` + Suggestions []string `json:"suggestions"` +} + +type InsightsSummaryReq struct { + AccountId string `form:"account_id,optional"` + Months int `form:"months,default=3"` +} + type InspireChatData struct { Session InspireSessionPublic `json:"session"` Messages []InspireMessagePublic `json:"messages"` @@ -530,6 +686,22 @@ type InviteParentCandidatesData struct { List []InviteMemberBrief `json:"list"` } +type InviteRewardPublic struct { + Id string `json:"id"` + InviteeUid int64 `json:"invitee_uid"` + PlanId string `json:"plan_id"` + Points int `json:"points"` + Status string `json:"status"` + CreatedAt int64 `json:"created_at"` +} + +type InviteRewardsSummary struct { + TotalPoints int `json:"total_points"` + MonthPoints int `json:"month_points"` + MonthCap int `json:"month_cap"` + Recent []InviteRewardPublic `json:"recent"` +} + type InviteTreeData struct { List []InviteTreeNode `json:"list"` } @@ -580,6 +752,17 @@ type JobPublic struct { CompletedAt int64 `json:"completed_at,optional"` } +type ListCheckupsReq struct { + Page int `form:"page,default=1"` + PageSize int `form:"pageSize,default=20"` +} + +type ListDraftReviewsReq struct { + Page int `form:"page,default=1"` + PageSize int `form:"pageSize,default=20"` + Status string `form:"status,optional"` +} + type ListModelsData struct { List []string `json:"list"` Provider string `json:"provider"` @@ -592,10 +775,32 @@ type ListModelsReq struct { Provider string `form:"provider,optional"` } +type ListOutcomesReq struct { + Page int `form:"page,default=1"` + PageSize int `form:"pageSize,default=20"` + Kind string `form:"kind,optional"` + Confidence string `form:"confidence,optional"` + SourceType string `form:"source_type,optional"` + From int64 `form:"from,optional"` + To int64 `form:"to,optional"` +} + type ListParentCandidatesReq struct { ExcludeSubtreeRootUid int64 `form:"exclude_subtree_root_uid,optional"` } +type ListPlaybooksReq struct { + Page int `form:"page,default=1"` + PageSize int `form:"pageSize,default=20"` + Kind string `form:"kind,optional"` + Niche string `form:"niche,optional"` + Mine bool `form:"mine,optional"` +} + +type ListWorkspaceMembersReq struct { + Id string `path:"id"` +} + type MediaUploadData struct { Url string `json:"url"` Key string `json:"key,optional"` @@ -685,10 +890,11 @@ type MoveInviteMemberReq struct { } type MyInviteNetworkData struct { - Me InviteMemberBrief `json:"me"` - Upline *InviteMemberBrief `json:"upline,optional"` - Downlines []InviteMemberBrief `json:"downlines"` - TotalDownlineCount int `json:"total_downline_count"` + Me InviteMemberBrief `json:"me"` + Upline *InviteMemberBrief `json:"upline,optional"` + Downlines []InviteMemberBrief `json:"downlines"` + TotalDownlineCount int `json:"total_downline_count"` + Rewards *InviteRewardsSummary `json:"rewards,optional"` } type NotificationIdPath struct { @@ -751,6 +957,48 @@ type OutboxStepPublic struct { PublishedAt int64 `json:"published_at,optional"` } +type OutcomeEventPublic struct { + Id string `json:"id"` + SourceType string `json:"source_type"` + SourceId string `json:"source_id"` + ThreadsAccountId string `json:"threads_account_id,optional"` + Kind string `json:"kind"` + Confidence string `json:"confidence"` + Status string `json:"status"` + ReplyCountDelta int `json:"reply_count_delta,optional"` + LikeCountDelta int `json:"like_count_delta,optional"` + FollowersDelta int `json:"followers_delta,optional"` + ConversionAmount float64 `json:"conversion_amount,optional"` + ConversionNote string `json:"conversion_note,optional"` + ConversionCurrency string `json:"conversion_currency,optional"` + WindowEndsAt int64 `json:"window_ends_at"` + SentAt int64 `json:"sent_at"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type OutcomeListData struct { + List []OutcomeEventPublic `json:"list"` + Pagination Pagination `json:"pagination"` +} + +type OutcomeSummaryData struct { + From int64 `json:"from"` + To int64 `json:"to"` + Reach int `json:"reach"` + Conversations int `json:"conversations"` + FollowsPossible int `json:"follows_possible"` + FollowsConfirmed int `json:"follows_confirmed"` + Conversions int `json:"conversions"` + ConversionAmount float64 `json:"conversion_amount"` +} + +type OutcomeSummaryReq struct { + Range string `form:"range,default=week"` // week | month + From int64 `form:"from,optional"` + To int64 `form:"to,optional"` +} + type OwnPostAnalyzeReq struct { PostId string `json:"post_id"` } @@ -838,6 +1086,19 @@ type Pagination struct { TotalPages int `json:"totalPages"` } +type PainKeywordsData struct { + Keywords []string `json:"keywords"` + Pains []string `json:"pains"` + ScanTerms []string `json:"scan_terms"` + Summary string `json:"summary"` + SignupHint string `json:"signup_hint"` +} + +type PainKeywordsReq struct { + ProductBrief string `json:"product_brief"` + Audience string `json:"audience,optional"` +} + type PersonaActiveData struct { Id string `json:"id"` } @@ -883,16 +1144,20 @@ type PersonaListData struct { } type PersonaPublic struct { - Id string `json:"id"` - Name string `json:"name"` - Brief string `json:"brief"` - Status string `json:"status"` - Style PersonaStylePublic `json:"style"` - Guard PersonaGuardPublic `json:"guard"` - Voice string `json:"voice,optional"` - Notes string `json:"notes,optional"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + Id string `json:"id"` + Name string `json:"name"` + Brief string `json:"brief"` + Status string `json:"status"` + Style PersonaStylePublic `json:"style"` + Guard PersonaGuardPublic `json:"guard"` + Voice string `json:"voice,optional"` + Notes string `json:"notes,optional"` + LearningSummary string `json:"learning_summary,optional"` + LearningVersion int `json:"learning_version,optional"` + LearnedFromPostsCount int `json:"learned_from_posts_count,optional"` + LastLearnedAt int64 `json:"last_learned_at,optional"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } type PersonaSaveReq struct { @@ -1011,6 +1276,28 @@ type PlayStepPublic struct { ImageUrls []string `json:"image_urls,optional"` } +type PlaybookIdPath struct { + Id string `path:"id"` +} + +type PlaybookListData struct { + List []PlaybookPublic `json:"list"` + Pagination Pagination `json:"pagination"` +} + +type PlaybookPublic struct { + Id string `json:"id"` + Kind string `json:"kind"` // brief | persona | play + Title string `json:"title"` + Niche string `json:"niche"` + Body string `json:"body"` + Anonymous bool `json:"anonymous"` + AuthorLabel string `json:"author_label"` + ImportCount int `json:"import_count"` + CreatedAt int64 `json:"created_at"` + Mine bool `json:"mine,optional"` +} + type ProductIdPath struct { Id string `path:"id"` } @@ -1045,6 +1332,26 @@ type ProductSaveReq struct { PlacementUrl string `json:"placement_url,optional"` } +type PublishPlaybookReq struct { + Kind string `json:"kind"` + Title string `json:"title"` + Niche string `json:"niche,optional"` + Body string `json:"body"` + Anonymous bool `json:"anonymous,optional"` +} + +type RemoveWorkspaceMemberReq struct { + Id string `path:"id"` + Uid int64 `path:"uid"` +} + +type ReportConversionReq struct { + Id string `path:"id"` + Amount float64 `json:"amount,optional"` + Note string `json:"note,optional"` + Currency string `json:"currency,optional"` +} + type ResearchHitPublic struct { Id string `json:"id"` Title string `json:"title"` @@ -1064,6 +1371,11 @@ type ResearchSearchReq struct { Query string `json:"query"` } +type ReviewDecisionReq struct { + Id string `path:"id"` + Reason string `json:"reason,optional"` +} + type SaveAiReq struct { Provider string `json:"provider,optional"` Model string `json:"model,optional"` @@ -1209,6 +1521,30 @@ type StyleDimensionPublic struct { Evidence []string `json:"evidence,optional"` } +type StyleQuizData struct { + Tone string `json:"tone"` + Rhythm string `json:"rhythm"` + Hooks string `json:"hooks"` + Avoid string `json:"avoid"` + Summary string `json:"summary"` + Tags []string `json:"tags"` + SignupHint string `json:"signup_hint"` +} + +type StyleQuizReq struct { + Samples string `json:"samples"` +} + +type SubmitDraftReviewReq struct { + RefType string `json:"ref_type"` + RefId string `json:"ref_id"` + WorkspaceId string `json:"workspace_id,optional"` +} + +type SwitchWorkspaceReq struct { + Id string `path:"id"` +} + type ThreadsAccountIdPath struct { Id string `path:"id"` } @@ -1287,6 +1623,19 @@ type UnreadCountData struct { Count int64 `json:"count"` } +type UpdateConversionReq struct { + Id string `path:"id"` + Amount float64 `json:"amount,optional"` + Note string `json:"note,optional"` + Currency string `json:"currency,optional"` +} + +type UpdateWorkspaceReq struct { + Id string `path:"id"` + Name string `json:"name,optional"` + ReviewRequired *bool `json:"review_required,optional"` +} + type UsageByokBlock struct { CallCount int `json:"call_count"` ByMeter map[string]UsageMeterCount `json:"by_meter,optional"` @@ -1435,6 +1784,25 @@ type UsageTenantSummaryReq struct { MonthKey string `form:"month_key,optional"` } +type UtmLinkListData struct { + List []UtmLinkPublic `json:"list"` +} + +type UtmLinkPublic struct { + Id string `json:"id"` + Code string `json:"code"` + TrackPath string `json:"track_path"` + DestinationUrl string `json:"destination_url"` + OutcomeId string `json:"outcome_id,optional"` + Label string `json:"label,optional"` + Clicks int `json:"clicks"` + CreatedAt int64 `json:"created_at"` +} + +type UtmRedirectReq struct { + Code string `path:"code"` +} + type ViralAnalysisPublic struct { Hooks string `json:"hooks"` Structure string `json:"structure"` @@ -1444,3 +1812,56 @@ type ViralAnalysisPublic struct { Copyable string `json:"copyable,optional"` Risks string `json:"risks,optional"` } + +type WeeklyCheckupPublic struct { + Id string `json:"id"` + WeekKey string `json:"week_key"` + Status string `json:"status"` + Summary string `json:"summary"` + Findings []CheckupFindingPublic `json:"findings"` + Actions []CheckupActionPublic `json:"actions"` + Error string `json:"error,optional"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type WorkspaceBrandingReq struct { + Id string `path:"id"` + FooterText string `json:"footer_text,optional"` + HidePoweredBy bool `json:"hide_powered_by,optional"` + BrandName string `json:"brand_name,optional"` +} + +type WorkspaceIdReq struct { + Id string `path:"id"` +} + +type WorkspaceListData struct { + List []WorkspacePublic `json:"list"` + CurrentWorkspaceId string `json:"current_workspace_id"` +} + +type WorkspaceMemberListData struct { + List []WorkspaceMemberPublic `json:"list"` +} + +type WorkspaceMemberPublic struct { + Uid int64 `json:"uid"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + JoinedAt int64 `json:"joined_at"` +} + +type WorkspacePublic struct { + Id string `json:"id"` + Name string `json:"name"` + ReviewRequired bool `json:"review_required"` + IsDefault bool `json:"is_default"` + Archived bool `json:"archived"` + FooterText string `json:"footer_text,optional"` + HidePoweredBy bool `json:"hide_powered_by,optional"` + BrandName string `json:"brand_name,optional"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} diff --git a/apps/extension/haixun-threads-sync/manifest.json b/apps/extension/haixun-threads-sync/manifest.json index f3a90e4..8ae97c0 100644 --- a/apps/extension/haixun-threads-sync/manifest.json +++ b/apps/extension/haixun-threads-sync/manifest.json @@ -22,7 +22,7 @@ "http://127.0.0.1:8888/*", "http://localhost:8890/*", "http://127.0.0.1:8890/*", - "https://threads-tool-dev.30cm.net/*", + "https://threads-tool.30cm.net/*", "https://*.30cm.net/*" ], "optional_host_permissions": ["http://*/*", "https://*/*"], @@ -47,7 +47,7 @@ "http://127.0.0.1:8888/*", "http://localhost:8890/*", "http://127.0.0.1:8890/*", - "https://threads-tool-dev.30cm.net/*", + "https://threads-tool.30cm.net/*", "https://*.30cm.net/*" ], "js": ["content-haixun.js"], diff --git a/apps/extension/haixun-threads-sync/options.html b/apps/extension/haixun-threads-sync/options.html index 79b4072..fdf8c0f 100644 --- a/apps/extension/haixun-threads-sync/options.html +++ b/apps/extension/haixun-threads-sync/options.html @@ -64,11 +64,11 @@ 127.0.0.1 不可混用)。

- 遠端例:https://threads-tool-dev.30cm.net
+ 遠端例:https://threads-tool.30cm.net
本機例:http://localhost:5173

- +
diff --git a/apps/extension/haixun-threads-sync/options.js b/apps/extension/haixun-threads-sync/options.js index 2dd2e88..10aaaba 100644 --- a/apps/extension/haixun-threads-sync/options.js +++ b/apps/extension/haixun-threads-sync/options.js @@ -2,7 +2,7 @@ const input = document.getElementById("serverUrl"); const saveBtn = document.getElementById("save"); const savedEl = document.getElementById("saved"); -const DEFAULT_URL = "https://threads-tool-dev.30cm.net"; +const DEFAULT_URL = "https://threads-tool.30cm.net"; chrome.storage.sync.get(["serverUrl"], ({ serverUrl }) => { input.value = serverUrl ?? DEFAULT_URL; diff --git a/apps/extension/haixun-threads-sync/service-worker.js b/apps/extension/haixun-threads-sync/service-worker.js index 8102f06..877d712 100644 --- a/apps/extension/haixun-threads-sync/service-worker.js +++ b/apps/extension/haixun-threads-sync/service-worker.js @@ -347,11 +347,11 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { async function bootstrapBridgeFromStorage() { try { const { serverUrl } = await chrome.storage.sync.get(["serverUrl"]); - // 預設同時覆蓋本機與遠端 dev + // 正式站優先,並保留本機開發網址。 const defaults = [ + "https://threads-tool.30cm.net", "http://localhost:5173", "http://127.0.0.1:5173", - "https://threads-tool-dev.30cm.net", ]; const primary = serverUrl || defaults[0]; if (!serverUrl) { diff --git a/apps/web/.env.example b/apps/web/.env.example index 5b2f882..a4b8254 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,7 +1,7 @@ # ── 前後端串接 ────────────────────────────────────────── # 預設建議:不設 VITE_API_BASE,讓請求走 same-origin # 瀏覽器 → Vite(/api) → proxy → gateway:8888 -# 遠端開 FE(threads-tool-dev.30cm.net)時必須這樣, +# 遠端開 FE(threads-tool.30cm.net)時必須這樣, # 不要寫死 http://127.0.0.1:8888(那會打到使用者自己的電腦)。 # # 本機若要直連 gateway(跳過 proxy):須將這個前端 origin 加入 diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index dbac713..c33b3fe 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -29,6 +29,15 @@ const StudioPage = lazy(() => import("./pages/StudioPage").then((m) => ({ defaul const VerifyEmailPage = lazy(() => import("./pages/VerifyEmailPage").then((m) => ({ default: m.VerifyEmailPage }))); const WizardPage = lazy(() => import("./pages/studio/WizardPage").then((m) => ({ default: m.WizardPage }))); const TodayPage = lazy(() => import("./pages/TodayPage").then((m) => ({ default: m.TodayPage }))); +const PlaybooksPage = lazy(() => import("./pages/PlaybooksPage").then((m) => ({ default: m.PlaybooksPage }))); +const BenchmarkPage = lazy(() => import("./pages/BenchmarkPage").then((m) => ({ default: m.BenchmarkPage }))); +const UtmLinksPage = lazy(() => import("./pages/UtmLinksPage").then((m) => ({ default: m.UtmLinksPage }))); +const StyleQuizPage = lazy(() => + import("./pages/tools/StyleQuizPage").then((m) => ({ default: m.StyleQuizPage })), +); +const PainKeywordsPage = lazy(() => + import("./pages/tools/PainKeywordsPage").then((m) => ({ default: m.PainKeywordsPage })), +); export default function App() { return ( @@ -43,6 +52,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> + } /> + } /> + } /> } /> } /> diff --git a/apps/web/src/components/layout/MobileDock.tsx b/apps/web/src/components/layout/MobileDock.tsx index 0633424..86a8e7c 100644 --- a/apps/web/src/components/layout/MobileDock.tsx +++ b/apps/web/src/components/layout/MobileDock.tsx @@ -6,6 +6,7 @@ import { isNavActive, mobileDockMoreKeys, mobileDockPrimaryKeys, + navGroupedItemsByKeys, navItemsByKeys, } from "../../lib/nav"; import { AppIcon } from "../ui/AppIcons"; @@ -20,7 +21,7 @@ export function MobileDock() { const titleId = useId(); const dockItems = navItemsByKeys(mobileDockPrimaryKeys); - const moreItems = navItemsByKeys(mobileDockMoreKeys); + const moreGroups = navGroupedItemsByKeys(mobileDockMoreKeys); const moreActive = isMoreNavActive(pathname); useEffect(() => { @@ -82,26 +83,31 @@ export function MobileDock() {

{t("nav.moreTitle")}

-
    - {moreItems.map((item) => { - const active = isNavActive(pathname, item); - return ( -
  • - -
  • - ); - })} -
+ {moreGroups.map(({ group, items }) => ( +
+

{t(group.labelKey)}

+
    + {items.map((item) => { + const active = isNavActive(pathname, item); + return ( +
  • + +
  • + ); + })} +
+
+ ))}
) : null} diff --git a/apps/web/src/components/layout/SidebarNav.tsx b/apps/web/src/components/layout/SidebarNav.tsx index d8c781e..fbd159c 100644 --- a/apps/web/src/components/layout/SidebarNav.tsx +++ b/apps/web/src/components/layout/SidebarNav.tsx @@ -1,6 +1,6 @@ import { NavLink, useLocation } from "react-router-dom"; import { useI18n } from "../../i18n/I18nContext"; -import { isNavActive, primaryNav } from "../../lib/nav"; +import { isNavActive, navGroups, navItemsByKeys } from "../../lib/nav"; import { AppIcon } from "../ui/AppIcons"; export function SidebarNav() { @@ -10,23 +10,28 @@ export function SidebarNav() { return ( ); } diff --git a/apps/web/src/components/layout/WorkspaceSwitcher.tsx b/apps/web/src/components/layout/WorkspaceSwitcher.tsx new file mode 100644 index 0000000..ab802fa --- /dev/null +++ b/apps/web/src/components/layout/WorkspaceSwitcher.tsx @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useState } from "react"; +import { useI18n } from "../../i18n/I18nContext"; +import { useRepos } from "../../data/DataContext"; +import type { Workspace } from "../../domain/types"; +import { AppIcon } from "../ui/AppIcons"; + +/** 頂欄 Workspace 切換(growth-loop P1) */ +export function WorkspaceSwitcher() { + const { t } = useI18n(); + const repos = useRepos(); + const [list, setList] = useState([]); + const [currentId, setCurrentId] = useState(""); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + try { + const res = await repos.growth.listWorkspaces(); + setList(res.list); + setCurrentId(res.current_workspace_id || res.list[0]?.id || ""); + } catch { + /* ignore until auth ready */ + } + }, [repos.growth]); + + useEffect(() => { + void load(); + }, [load]); + + if (list.length === 0) return null; + + return ( + + ); +} diff --git a/apps/web/src/components/persona/PersonaWorkbench.tsx b/apps/web/src/components/persona/PersonaWorkbench.tsx index f436389..b0d7f4f 100644 --- a/apps/web/src/components/persona/PersonaWorkbench.tsx +++ b/apps/web/src/components/persona/PersonaWorkbench.tsx @@ -369,6 +369,11 @@ export function PersonaWorkbench() { {activeId === p.id ? {t("persona.default")} : null} {toneOf(p) || t("common.dash")} + {p.learning_summary ? ( + + {p.learning_summary} + + ) : null} ))} diff --git a/apps/web/src/components/ui/AppIcons.tsx b/apps/web/src/components/ui/AppIcons.tsx index 58b1804..accc00e 100644 --- a/apps/web/src/components/ui/AppIcons.tsx +++ b/apps/web/src/components/ui/AppIcons.tsx @@ -130,6 +130,33 @@ const glyphs: Record = { ), + playbooks: ( + <> + + + + ), + insights: ( + <> + + + + + ), + benchmark: ( + <> + + + + + ), + utm: ( + <> + + + + + ), more: ( <> diff --git a/apps/web/src/data/live/growthRepo.ts b/apps/web/src/data/live/growthRepo.ts new file mode 100644 index 0000000..393785a --- /dev/null +++ b/apps/web/src/data/live/growthRepo.ts @@ -0,0 +1,179 @@ +import type { + AccountHealth, + OutcomeEvent, + OutcomeSummary, + WeeklyCheckup, + Workspace, +} from "../../domain/types"; +import type { GrowthRepo } from "../repos"; +import { apiRequest } from "./http"; + +function mapOutcome(raw: Record): OutcomeEvent { + return { + id: String(raw.id ?? ""), + source_type: String(raw.source_type ?? ""), + source_id: String(raw.source_id ?? ""), + threads_account_id: raw.threads_account_id ? String(raw.threads_account_id) : undefined, + kind: String(raw.kind ?? ""), + confidence: String(raw.confidence ?? ""), + status: String(raw.status ?? ""), + reply_count_delta: Number(raw.reply_count_delta ?? 0), + like_count_delta: Number(raw.like_count_delta ?? 0), + followers_delta: Number(raw.followers_delta ?? 0), + conversion_amount: Number(raw.conversion_amount ?? 0), + conversion_note: raw.conversion_note ? String(raw.conversion_note) : undefined, + conversion_currency: raw.conversion_currency ? String(raw.conversion_currency) : undefined, + window_ends_at: Number(raw.window_ends_at ?? 0), + sent_at: Number(raw.sent_at ?? 0), + created_at: Number(raw.created_at ?? 0), + updated_at: Number(raw.updated_at ?? 0), + }; +} + +function mapSummary(raw: Record): OutcomeSummary { + return { + from: Number(raw.from ?? 0), + to: Number(raw.to ?? 0), + reach: Number(raw.reach ?? 0), + conversations: Number(raw.conversations ?? 0), + follows_possible: Number(raw.follows_possible ?? 0), + follows_confirmed: Number(raw.follows_confirmed ?? 0), + conversions: Number(raw.conversions ?? 0), + conversion_amount: Number(raw.conversion_amount ?? 0), + }; +} + +function outcomeSummaryQuery(range: string): string { + const now = new Date(); + let start: Date | null = null; + if (range === "week") { + start = new Date(now); + const daysSinceMonday = (now.getDay() + 6) % 7; + start.setDate(now.getDate() - daysSinceMonday); + start.setHours(0, 0, 0, 0); + } else if (range === "month") { + start = new Date(now.getFullYear(), now.getMonth(), 1); + } + if (!start) return `range=${encodeURIComponent(range)}`; + const from = Math.trunc(start.getTime()) * 1_000_000; + const to = Math.trunc(now.getTime()) * 1_000_000; + return `from=${from}&to=${to}`; +} + +function mapCheckup(raw: Record): WeeklyCheckup { + const findingsRaw = Array.isArray(raw.findings) ? raw.findings : []; + const actionsRaw = Array.isArray(raw.actions) ? raw.actions : []; + return { + id: String(raw.id ?? ""), + week_key: String(raw.week_key ?? ""), + status: String(raw.status ?? ""), + summary: String(raw.summary ?? ""), + findings: findingsRaw.map((f) => { + const row = f as Record; + return { + claim: String(row.claim ?? ""), + evidence: Array.isArray(row.evidence) ? row.evidence.map(String) : [], + }; + }), + actions: actionsRaw.map((a) => { + const row = a as Record; + return { + title: String(row.title ?? ""), + reason: String(row.reason ?? ""), + deeplink: String(row.deeplink ?? "today"), + }; + }), + error: raw.error ? String(raw.error) : undefined, + created_at: Number(raw.created_at ?? 0), + updated_at: Number(raw.updated_at ?? 0), + }; +} + +export function createLiveGrowthRepo(): GrowthRepo { + return { + async getOutcomeSummary(range = "week") { + const data = await apiRequest>( + `/api/v1/outcomes/summary?${outcomeSummaryQuery(range)}`, + ); + return mapSummary(data); + }, + async listOutcomes(page = 1, pageSize = 20) { + const data = await apiRequest<{ list?: Record[] }>( + `/api/v1/outcomes?page=${page}&pageSize=${pageSize}`, + ); + return (data.list ?? []).map(mapOutcome); + }, + async reportConversion(id, amount, note, currency) { + const data = await apiRequest>( + `/api/v1/outcomes/${encodeURIComponent(id)}/conversion`, + { + method: "POST", + body: { amount: amount ?? 0, note: note ?? "", currency: currency ?? "TWD" }, + }, + ); + return mapOutcome(data); + }, + async getLatestCheckup() { + const data = await apiRequest>("/api/v1/checkups/latest"); + return mapCheckup(data); + }, + async generateCheckupNow() { + const data = await apiRequest>("/api/v1/checkups/generate", { + method: "POST", + body: {}, + }); + return mapCheckup(data); + }, + async listAccountHealth() { + const data = await apiRequest<{ list?: Record[] }>("/api/v1/account-health"); + return (data.list ?? []).map((raw) => ({ + threads_account_id: String(raw.threads_account_id ?? ""), + score: Number(raw.score ?? 0), + level: String(raw.level ?? "normal"), + factors: Array.isArray(raw.factors) ? raw.factors.map(String) : [], + advice: String(raw.advice ?? ""), + computed_at: Number(raw.computed_at ?? 0), + })) as AccountHealth[]; + }, + async listWorkspaces() { + const data = await apiRequest<{ + list?: Record[]; + current_workspace_id?: string; + }>("/api/v1/workspaces"); + const list = (data.list ?? []).map(mapWorkspace); + return { list, current_workspace_id: String(data.current_workspace_id ?? "") }; + }, + async switchWorkspace(id) { + const data = await apiRequest<{ + list?: Record[]; + current_workspace_id?: string; + }>(`/api/v1/workspaces/${encodeURIComponent(id)}/switch`, { + method: "POST", + body: {}, + }); + return { + list: (data.list ?? []).map(mapWorkspace), + current_workspace_id: String(data.current_workspace_id ?? id), + }; + }, + async createWorkspace(name) { + const raw = await apiRequest>("/api/v1/workspaces", { + method: "POST", + body: { name }, + }); + return mapWorkspace(raw); + }, + }; +} + +function mapWorkspace(raw: Record): Workspace { + return { + id: String(raw.id ?? ""), + name: String(raw.name ?? ""), + review_required: Boolean(raw.review_required), + is_default: Boolean(raw.is_default), + archived: Boolean(raw.archived), + created_at: Number(raw.created_at ?? 0), + updated_at: Number(raw.updated_at ?? 0), + }; +} diff --git a/apps/web/src/data/live/http.ts b/apps/web/src/data/live/http.ts index ea49449..48dea9d 100644 --- a/apps/web/src/data/live/http.ts +++ b/apps/web/src/data/live/http.ts @@ -4,7 +4,7 @@ * API base 規則(前後端要串得起來): * 1. VITE_API_BASE 有設 → 用它(例如本機直連 http://127.0.0.1:8888) * 2. 未設 → 空字串 = same-origin,走 Vite proxy `/api` → gateway:8888 - * (遠端開 threads-tool-dev.30cm.net 時不能打瀏覽器本機 127.0.0.1) + * (遠端開 threads-tool.30cm.net 時不能打瀏覽器本機 127.0.0.1) */ import { messageForApiCode } from "../../lib/apiErrors"; diff --git a/apps/web/src/data/live/inviteRepo.ts b/apps/web/src/data/live/inviteRepo.ts index 057525b..b4887fd 100644 --- a/apps/web/src/data/live/inviteRepo.ts +++ b/apps/web/src/data/live/inviteRepo.ts @@ -53,11 +53,33 @@ function mapNetwork(raw: Record): MyInviteNetwork { const me = mapBrief((raw.me || {}) as Record); const uplineRaw = raw.upline as Record | null | undefined; const downRaw = Array.isArray(raw.downlines) ? raw.downlines : []; + const rewardsRaw = raw.rewards as Record | null | undefined; + let rewards: MyInviteNetwork["rewards"] = null; + if (rewardsRaw) { + const recentRaw = Array.isArray(rewardsRaw.recent) ? rewardsRaw.recent : []; + rewards = { + total_points: Number(rewardsRaw.total_points ?? 0), + month_points: Number(rewardsRaw.month_points ?? 0), + month_cap: Number(rewardsRaw.month_cap ?? 0), + recent: recentRaw.map((r) => { + const row = r as Record; + return { + id: String(row.id ?? ""), + invitee_uid: uidToString(row.invitee_uid), + plan_id: String(row.plan_id ?? ""), + points: Number(row.points ?? 0), + status: String(row.status ?? ""), + created_at: Number(row.created_at ?? 0), + }; + }), + }; + } return { me, upline: uplineRaw ? mapBrief(uplineRaw) : null, downlines: downRaw.map((d) => mapBrief(d as Record)), total_downline_count: Number(raw.total_downline_count ?? me.total_downline_count ?? 0), + rewards, }; } diff --git a/apps/web/src/data/live/m5Repos.ts b/apps/web/src/data/live/m5Repos.ts index 7e63bd1..bbd14a9 100644 --- a/apps/web/src/data/live/m5Repos.ts +++ b/apps/web/src/data/live/m5Repos.ts @@ -107,6 +107,11 @@ function mapBrand(raw: Record): Brand { brief: String(raw.brief ?? ""), target_audience: raw.target_audience != null ? String(raw.target_audience) : undefined, goals: raw.goals != null ? String(raw.goals) : undefined, + learning_summary: raw.learning_summary != null ? String(raw.learning_summary) : undefined, + learning_version: raw.learning_version != null ? Number(raw.learning_version) : undefined, + learned_from_posts_count: + raw.learned_from_posts_count != null ? Number(raw.learned_from_posts_count) : undefined, + last_learned_at: raw.last_learned_at != null ? Number(raw.last_learned_at) : undefined, }; } diff --git a/apps/web/src/data/live/repos.ts b/apps/web/src/data/live/repos.ts index de3a853..c36cf7c 100644 --- a/apps/web/src/data/live/repos.ts +++ b/apps/web/src/data/live/repos.ts @@ -60,6 +60,7 @@ import { createLiveScout, } from "./m5Repos"; import { createLiveInviteRepo } from "./inviteRepo"; +import { createLiveGrowthRepo } from "./growthRepo"; import { ApiError, apiRequest, loadTokens, saveTokens, type StoredTokens } from "./http"; function uidToString(uid: unknown): string { @@ -1054,6 +1055,11 @@ function mapPersona(raw: Record): Persona { }, voice: raw.voice != null ? String(raw.voice) : undefined, notes: raw.notes != null ? String(raw.notes) : undefined, + learning_summary: raw.learning_summary != null ? String(raw.learning_summary) : undefined, + learning_version: raw.learning_version != null ? Number(raw.learning_version) : undefined, + learned_from_posts_count: + raw.learned_from_posts_count != null ? Number(raw.learned_from_posts_count) : undefined, + last_learned_at: raw.last_learned_at != null ? Number(raw.last_learned_at) : undefined, }; } @@ -1646,5 +1652,6 @@ export function createLiveRepos(): Repos { research: createLiveResearch(), media: createLiveMedia(), scout: createLiveScout(), + growth: createLiveGrowthRepo(), }; } diff --git a/apps/web/src/data/repos.ts b/apps/web/src/data/repos.ts index 8748228..b9b5ef2 100644 --- a/apps/web/src/data/repos.ts +++ b/apps/web/src/data/repos.ts @@ -621,6 +621,29 @@ export type BillingSubscription = { export type BillingPortalSession = { url: string }; +export type GrowthRepo = { + getOutcomeSummary(range?: "week" | "month"): Promise; + listOutcomes(page?: number, pageSize?: number): Promise; + reportConversion( + id: string, + amount?: number, + note?: string, + currency?: string, + ): Promise; + getLatestCheckup(): Promise; + generateCheckupNow(): Promise; + listAccountHealth(): Promise; + listWorkspaces(): Promise<{ + list: import("../domain/types").Workspace[]; + current_workspace_id: string; + }>; + switchWorkspace(id: string): Promise<{ + list: import("../domain/types").Workspace[]; + current_workspace_id: string; + }>; + createWorkspace(name: string): Promise; +}; + export type Repos = { dataSource: DataSource; auth: AuthRepo; @@ -641,4 +664,5 @@ export type Repos = { usage: UsageRepo; adminUsers: AdminUsersRepo; invite: InviteRepo; + growth: GrowthRepo; }; diff --git a/apps/web/src/domain/types.ts b/apps/web/src/domain/types.ts index 912e888..4f43710 100644 --- a/apps/web/src/domain/types.ts +++ b/apps/web/src/domain/types.ts @@ -84,6 +84,23 @@ export type InviteTreeNode = InviteMemberBrief & { children: InviteTreeNode[]; }; +/** 邀請獎勵(growth-loop) */ +export type InviteReward = { + id: string; + invitee_uid: string; + plan_id: string; + points: number; + status: "credited" | "capped" | "skipped" | string; + created_at: number; +}; + +export type InviteRewardsSummary = { + total_points: number; + month_points: number; + month_cap: number; + recent: InviteReward[]; +}; + /** 一般會員:自己的邀請碼 + 上下線 */ export type MyInviteNetwork = { me: InviteMemberBrief; @@ -93,6 +110,71 @@ export type MyInviteNetwork = { downlines: InviteMemberBrief[]; /** 全部子孫人數(含間接) */ total_downline_count: number; + /** 邀請點數回饋(可選) */ + rewards?: InviteRewardsSummary | null; +}; + +/** 成果歸因(growth-loop) */ +export type OutcomeEvent = { + id: string; + source_type: string; + source_id: string; + threads_account_id?: string; + kind: string; + confidence: "confirmed" | "possible" | string; + status: string; + reply_count_delta?: number; + like_count_delta?: number; + followers_delta?: number; + conversion_amount?: number; + conversion_note?: string; + conversion_currency?: string; + window_ends_at: number; + sent_at: number; + created_at: number; + updated_at: number; +}; + +export type OutcomeSummary = { + from: number; + to: number; + reach: number; + conversations: number; + follows_possible: number; + follows_confirmed: number; + conversions: number; + conversion_amount: number; +}; + +export type WeeklyCheckup = { + id: string; + week_key: string; + status: string; + summary: string; + findings: { claim: string; evidence: string[] }[]; + actions: { title: string; reason: string; deeplink: string }[]; + error?: string; + created_at: number; + updated_at: number; +}; + +export type AccountHealth = { + threads_account_id: string; + score: number; + level: "normal" | "warn" | "throttle" | string; + factors: string[]; + advice: string; + computed_at: number; +}; + +export type Workspace = { + id: string; + name: string; + review_required: boolean; + is_default: boolean; + archived: boolean; + created_at: number; + updated_at: number; }; export type ConnectionStatus = "unknown" | "connected" | "error" | "disconnected"; @@ -334,6 +416,11 @@ export type Persona = { voice?: string; /** @deprecated 相容 */ notes?: string; + /** growth-loop 資產複利 */ + learning_summary?: string; + learning_version?: number; + learned_from_posts_count?: number; + last_learned_at?: number; }; /** 留言是否已被我們回過 */ @@ -615,6 +702,10 @@ export type Brand = { target_audience?: string; /** 這輪目標:軟性經驗分享、導連結… */ goals?: string; + learning_summary?: string; + learning_version?: number; + learned_from_posts_count?: number; + last_learned_at?: number; }; /** diff --git a/apps/web/src/lib/i18n/messages.ts b/apps/web/src/lib/i18n/messages.ts index e0036e0..8c9ec04 100644 --- a/apps/web/src/lib/i18n/messages.ts +++ b/apps/web/src/lib/i18n/messages.ts @@ -16,6 +16,10 @@ export const zhTW: MessageDict = { "nav.outbox": "發送", "nav.jobs": "任務", "nav.brands": "品牌", + "nav.playbooks": "市集", + "nav.insights": "成效", + "nav.benchmark": "基準", + "nav.utm": "追蹤", "nav.more": "更多", "nav.moreTitle": "更多功能", "nav.users": "島民管理", @@ -25,6 +29,14 @@ export const zhTW: MessageDict = { "nav.settings": "系統設定", "nav.logout": "登出", "nav.navigate": "導覽", + "navGroup.workflow": "主流程", + "navGroup.accounts": "帳號品牌", + "navGroup.growth": "成長工具", + + "workspace.label": "工作區", + "workspace.default": "預設", + "workspace.new": "+ 新增工作區", + "workspace.newPrompt": "新工作區名稱", "common.save": "儲存", "common.cancel": "取消", @@ -275,6 +287,8 @@ export const zhTW: MessageDict = { "invite.copied": "已複製", "invite.copyFail": "複製失敗", "invite.stats": "直邀 {direct} · 延伸 {total}", + "invite.rewards.summary": "邀請贈點累計 {total} · 本月 {month} /上限 {cap}", + "invite.rewards.title": "邀請回饋紀錄", "invite.upline": "邀請人", "invite.downlines": "直邀成員 · {n}", "invite.noUpline": "無邀請人", @@ -363,6 +377,7 @@ export const zhTW: MessageDict = { "crew.health.needsReconnectHint": "token 無法使用,請按「連接帳號」重新授權(不是延長 token)", "crew.health.disconnectedHint": "已解除綁定", "crew.health.expiredHint": "請延長 token 或重新連帳", + "crew.opHealthScore": "操作 {n}", "crew.msg.refreshed": "@{user} 已用現有授權延長 token(約 60 天)", "crew.msg.refreshedAll": "已用現有授權延長 {n} 個帳號 token", "crew.msg.refreshFail": "延長失敗(token 失效時請重新「連接帳號」)", @@ -377,9 +392,20 @@ export const zhTW: MessageDict = { "today.reload": "重新整理", "today.loadFail": "無法載入今日資料", "today.trendsFail": "無法刷新話題(可能額度不足或搜尋未設定)", + "today.trendsUpdated": "已更新 {n} 個話題", "today.syncPosts": "同步已發文", "today.syncPostsFail": "同步貼文失敗", "today.needAccount": "請先連接 Threads 帳號", + "today.outcome.title": "本週成果", + "today.outcome.reach": "觸達", + "today.outcome.conversations": "對話", + "today.outcome.follows": "追蹤", + "today.outcome.followsHint": "可能相關,尚無強訊號", + "today.outcome.followsConfirmedHint": "/確定 {n}", + "today.outcome.conversions": "成交", + "today.outcome.emptyHint": "本週還沒有海巡外展成果,去海巡試試身手?", + "today.checkup.empty": "尚未產生本週健檢,將於下週一(依你的時區)自動產生。", + "today.checkup.prefix": "健檢:", "today.pendingReplies": "待回覆", "today.pendingRepliesN": "待回覆 · {n}", "today.newThread": "寫一則", @@ -1641,6 +1667,10 @@ export const zhTW: MessageDict = { "usage.planMeta": "/月 · 每月 {n} 點額度", "usage.changePlan": "變更方案", "usage.upgradePlan": "升級方案", + "usage.outcome.title": "本月成果", + "usage.outcome.summary": "觸達 {reach} · 對話 {conversations} · 成交 {conversions}", + "usage.outcome.amount": "(約 ${amount})", + "usage.outcome.emptyHint": "本月還沒有可歸因的成果,去海巡或發文試試。", "usage.warn.unlimitedOver": "本月已用 {used} 點(方案 {cap},不擋額度,仍可繼續)。", "usage.warn.exhausted": "本月點數已用完。可升級方案或等待下月重置。", "usage.warn.high": "本月已用 {pct}% 點數。", @@ -1738,6 +1768,10 @@ export const en: MessageDict = { "nav.outbox": "Outbox", "nav.jobs": "Jobs", "nav.brands": "Brands", + "nav.playbooks": "Playbooks", + "nav.insights": "Insights", + "nav.benchmark": "Benchmark", + "nav.utm": "UTM", "nav.more": "More", "nav.moreTitle": "More", "nav.users": "Islanders", @@ -1747,6 +1781,14 @@ export const en: MessageDict = { "nav.settings": "Settings", "nav.logout": "Log out", "nav.navigate": "Navigate", + "navGroup.workflow": "Workflow", + "navGroup.accounts": "Accounts & brands", + "navGroup.growth": "Growth tools", + + "workspace.label": "Workspace", + "workspace.default": "Default", + "workspace.new": "+ New workspace", + "workspace.newPrompt": "New workspace name", "common.save": "Save", "common.cancel": "Cancel", @@ -1996,6 +2038,8 @@ export const en: MessageDict = { "invite.copied": "Copied", "invite.copyFail": "Copy failed", "invite.stats": "{direct} direct · {total} in chain", + "invite.rewards.summary": "Reward points total {total} · this month {month} / cap {cap}", + "invite.rewards.title": "Reward history", "invite.upline": "Invited by", "invite.downlines": "Direct invites · {n}", "invite.noUpline": "No inviter", @@ -2084,6 +2128,7 @@ export const en: MessageDict = { "crew.health.needsReconnectHint": "Token unusable — use Connect account (not Extend token)", "crew.health.disconnectedHint": "Unlinked", "crew.health.expiredHint": "Extend token or reconnect", + "crew.opHealthScore": "Health {n}", "crew.msg.refreshed": "@{user} token extended with stored auth (~60 days)", "crew.msg.refreshedAll": "Extended {n} account tokens with stored auth", "crew.msg.refreshFail": "Extend failed (reconnect if token is invalid)", @@ -2098,9 +2143,20 @@ export const en: MessageDict = { "today.reload": "Reload", "today.loadFail": "Could not load Today", "today.trendsFail": "Could not refresh topics (quota or search not set)", + "today.trendsUpdated": "Updated {n} topics", "today.syncPosts": "Sync own posts", "today.syncPostsFail": "Could not sync posts", "today.needAccount": "Connect a Threads account first", + "today.outcome.title": "This week's outcomes", + "today.outcome.reach": "Reach", + "today.outcome.conversations": "Conversations", + "today.outcome.follows": "Follows", + "today.outcome.followsHint": "Possibly related, no strong signal yet", + "today.outcome.followsConfirmedHint": " / confirmed {n}", + "today.outcome.conversions": "Conversions", + "today.outcome.emptyHint": "No patrol outreach outcomes this week yet — go give patrol a try?", + "today.checkup.empty": "This week's checkup hasn't been generated yet. It runs automatically next Monday in your timezone.", + "today.checkup.prefix": "Checkup: ", "today.pendingReplies": "Pending replies", "today.pendingRepliesN": "Pending · {n}", "today.newThread": "Compose", @@ -3362,6 +3418,10 @@ export const en: MessageDict = { "usage.planMeta": "/ mo · {n} credits monthly", "usage.changePlan": "Change plan", "usage.upgradePlan": "Upgrade plan", + "usage.outcome.title": "This month's outcomes", + "usage.outcome.summary": "Reach {reach} · Conversations {conversations} · Conversions {conversions}", + "usage.outcome.amount": " (~${amount})", + "usage.outcome.emptyHint": "No attributable outcomes this month yet — try patrol or posting.", "usage.warn.unlimitedOver": "Used {used} credits this month (plan {cap}; limits off — you can continue).", "usage.warn.exhausted": "Monthly credits are used up. Upgrade or wait for next month.", "usage.warn.high": "You've used {pct}% of monthly credits.", diff --git a/apps/web/src/lib/nav.ts b/apps/web/src/lib/nav.ts index 31555d1..6fec06c 100644 --- a/apps/web/src/lib/nav.ts +++ b/apps/web/src/lib/nav.ts @@ -1,4 +1,15 @@ -export type NavKey = "today" | "crew" | "studio" | "scout" | "outbox" | "jobs" | "brands"; +export type NavKey = + | "today" + | "crew" + | "studio" + | "scout" + | "outbox" + | "jobs" + | "brands" + | "playbooks" + | "insights" + | "benchmark" + | "utm"; export type NavItem = { key: NavKey; @@ -19,19 +30,79 @@ export const primaryNav: NavItem[] = [ { key: "outbox", path: "/app/outbox", labelKey: "nav.outbox", label: "發送", en: "Outbox" }, { key: "jobs", path: "/app/jobs", labelKey: "nav.jobs", label: "任務", en: "Jobs" }, { key: "brands", path: "/app/brands", labelKey: "nav.brands", label: "品牌", en: "Brands" }, + { + key: "playbooks", + path: "/app/playbooks", + labelKey: "nav.playbooks", + label: "市集", + en: "Playbooks", + }, + { + key: "insights", + path: "/app/insights", + labelKey: "nav.insights", + label: "成效", + en: "Insights", + }, + { + key: "benchmark", + path: "/app/benchmark", + labelKey: "nav.benchmark", + label: "基準", + en: "Benchmark", + }, + { key: "utm", path: "/app/utm", labelKey: "nav.utm", label: "追蹤", en: "UTM" }, ]; /** 手機底欄固定 4 格(主流程) */ export const mobileDockPrimaryKeys: NavKey[] = ["today", "studio", "scout", "outbox"]; /** 手機底欄「更多」內項目 */ -export const mobileDockMoreKeys: NavKey[] = ["crew", "jobs", "brands"]; +export const mobileDockMoreKeys: NavKey[] = [ + "crew", + "jobs", + "brands", + "playbooks", + "insights", + "benchmark", + "utm", +]; + +export type NavGroupKey = "workflow" | "accounts" | "growth"; + +export type NavGroup = { + key: NavGroupKey; + /** i18n key,例如 navGroup.workflow */ + labelKey: string; + keys: NavKey[]; +}; + +/** 側欄二級分類:主流程 → 帳號品牌 → 成長工具,避免 11 項全部散在同一層 */ +export const navGroups: NavGroup[] = [ + { key: "workflow", labelKey: "navGroup.workflow", keys: ["today", "studio", "scout", "outbox", "jobs"] }, + { key: "accounts", labelKey: "navGroup.accounts", keys: ["crew", "brands"] }, + { + key: "growth", + labelKey: "navGroup.growth", + keys: ["insights", "benchmark", "playbooks", "utm"], + }, +]; export function navItemsByKeys(keys: NavKey[]): NavItem[] { const map = new Map(primaryNav.map((n) => [n.key, n])); return keys.map((k) => map.get(k)).filter((x): x is NavItem => Boolean(x)); } +/** 依 navGroups 分類,只保留 keys 中真的存在的項目;空分類不回傳。 */ +export function navGroupedItemsByKeys( + keys: NavKey[], +): { group: NavGroup; items: NavItem[] }[] { + const allow = new Set(keys); + return navGroups + .map((group) => ({ group, items: navItemsByKeys(group.keys.filter((k) => allow.has(k))) })) + .filter(({ items }) => items.length > 0); +} + export function isNavActive(pathname: string, item: NavItem): boolean { if (item.path === "/app/today") { return pathname === "/app" || pathname === "/app/today"; diff --git a/apps/web/src/pages/BenchmarkPage.tsx b/apps/web/src/pages/BenchmarkPage.tsx new file mode 100644 index 0000000..e9f20b1 --- /dev/null +++ b/apps/web/src/pages/BenchmarkPage.tsx @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useState } from "react"; +import { PageHeader } from "../components/layout/PageHeader"; +import { Button, Card, Input } from "../components/ui"; +import { apiRequest } from "../data/live/http"; + +type Bench = { + available: boolean; + sample_size: number; + niche: string; + median_eng_rate: number; + median_views: number; + your_eng_rate?: number; + your_views?: number; + percentile_hint?: string; + message?: string; +}; + +export function BenchmarkPage() { + const [niche, setNiche] = useState("general"); + const [data, setData] = useState(null); + const [err, setErr] = useState(""); + + const load = useCallback(async () => { + setErr(""); + try { + const q = new URLSearchParams({ niche }); + setData(await apiRequest(`/api/v1/benchmark?${q}`)); + } catch (e) { + setErr(e instanceof Error ? e.message : "fail"); + } + }, [niche]); + + useEffect(() => { + void load(); + }, [load]); + + return ( + <> + +
+ setNiche(e.target.value)} placeholder="niche" /> + +
+ {err ?

{err}

: null} + {data ? ( + +

利基:{data.niche} · 樣本 {data.sample_size}

+ {data.available ? ( + <> +

+ 中位互動率 {(data.median_eng_rate * 100).toFixed(2)}% · 中位瀏覽{" "} + {data.median_views.toFixed(0)} +

+

+ 你的互動率 {((data.your_eng_rate ?? 0) * 100).toFixed(2)}% · 均覽{" "} + {(data.your_views ?? 0).toFixed(0)} +

+

{data.percentile_hint}

+ + ) : ( +

{data.message || "樣本不足"}

+ )} +
+ ) : null} + + ); +} diff --git a/apps/web/src/pages/BrandsPage.tsx b/apps/web/src/pages/BrandsPage.tsx index d4a9c2b..666b4dc 100644 --- a/apps/web/src/pages/BrandsPage.tsx +++ b/apps/web/src/pages/BrandsPage.tsx @@ -424,6 +424,9 @@ export function BrandsPage() {

{selected.display_name}

{t("brands.inUseHint")}

+ {selected.learning_summary ? ( +

{selected.learning_summary}

+ ) : null}
{t("brands.inUse")} diff --git a/apps/web/src/pages/CrewPage.tsx b/apps/web/src/pages/CrewPage.tsx index 32936da..96c075c 100644 --- a/apps/web/src/pages/CrewPage.tsx +++ b/apps/web/src/pages/CrewPage.tsx @@ -3,7 +3,7 @@ import { PageHeader } from "../components/layout/PageHeader"; import { PersonaWorkbench } from "../components/persona/PersonaWorkbench"; import { AccountAvatar, Badge, Button, EmptyState, Pager } from "../components/ui"; import { useData, useRepos } from "../data/DataContext"; -import type { ThreadsAccount } from "../domain/types"; +import type { AccountHealth, ThreadsAccount } from "../domain/types"; import { useI18n } from "../i18n/I18nContext"; import { pageSlice } from "../lib/pagination"; import { formatLocalDateTime, formatSessionExpiry } from "../lib/time"; @@ -85,6 +85,7 @@ export function CrewPage() { const { t } = useI18n(); const [tab, setTab] = useState("accounts"); const [accounts, setAccounts] = useState([]); + const [opHealth, setOpHealth] = useState>({}); const [accPage, setAccPage] = useState(1); const [busy, setBusy] = useState(""); const [message, setMessage] = useState(""); @@ -92,7 +93,11 @@ export function CrewPage() { const load = useCallback(async () => { setAccounts(await repos.accounts.list()); - }, [repos.accounts]); + const list = await repos.growth.listAccountHealth().catch(() => [] as AccountHealth[]); + const map: Record = {}; + for (const h of list) map[h.threads_account_id] = h; + setOpHealth(map); + }, [repos.accounts, repos.growth]); const pagedAccounts = useMemo( () => pageSlice(accounts, accPage, ACCOUNTS_PAGE), @@ -236,10 +241,26 @@ export function CrewPage() {
{health.label} + {opHealth[acc.id] ? ( + + {t("crew.opHealthScore", { n: opHealth[acc.id].score })} + + ) : null}
{health.detail ? (

{health.detail}

) : null} + {opHealth[acc.id]?.advice ? ( +

{opHealth[acc.id].advice}

+ ) : null}
)} + {network?.rewards && network.rewards.recent.length > 0 ? ( + +
    + {network.rewards.recent.map((r) => ( +
  • +
    + +{r.points} + + {" "} + · {r.plan_id} · {r.status} + +
    +
  • + ))} +
+
+ ) : null} + {!network ? (

{t("common.loading")}

diff --git a/apps/web/src/pages/PlaybooksPage.tsx b/apps/web/src/pages/PlaybooksPage.tsx new file mode 100644 index 0000000..3e77a1a --- /dev/null +++ b/apps/web/src/pages/PlaybooksPage.tsx @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useState } from "react"; +import { PageHeader } from "../components/layout/PageHeader"; +import { Badge, Button, Card, EmptyState, Input, Textarea } from "../components/ui"; +import { useRepos } from "../data/DataContext"; +import { apiRequest } from "../data/live/http"; + +type Playbook = { + id: string; + kind: string; + title: string; + niche: string; + body: string; + anonymous: boolean; + author_label: string; + import_count: number; + created_at: number; + mine?: boolean; +}; + +export function PlaybooksPage() { + const repos = useRepos(); + const [list, setList] = useState([]); + const [mine, setMine] = useState(false); + const [kind, setKind] = useState(""); + const [niche, setNiche] = useState(""); + const [busy, setBusy] = useState(""); + const [msg, setMsg] = useState(""); + const [showPub, setShowPub] = useState(false); + const [form, setForm] = useState({ kind: "brief", title: "", niche: "", body: "", anonymous: true }); + + const load = useCallback(async () => { + const q = new URLSearchParams({ page: "1", pageSize: "50" }); + if (kind) q.set("kind", kind); + if (niche) q.set("niche", niche); + if (mine) q.set("mine", "true"); + const data = await apiRequest<{ list?: Playbook[] }>(`/api/v1/playbooks?${q}`); + setList(data.list ?? []); + }, [kind, niche, mine]); + + useEffect(() => { + void load().catch((e) => setMsg(e instanceof Error ? e.message : "load fail")); + }, [load]); + + async function publish() { + setBusy("pub"); + try { + await apiRequest("/api/v1/playbooks", { method: "POST", body: form }); + setShowPub(false); + setForm({ kind: "brief", title: "", niche: "", body: "", anonymous: true }); + await load(); + setMsg("已發布"); + } catch (e) { + setMsg(e instanceof Error ? e.message : "fail"); + } finally { + setBusy(""); + } + } + + async function importPb(id: string) { + setBusy(id); + try { + await apiRequest(`/api/v1/playbooks/${id}/import`, { method: "POST", body: {} }); + setMsg("已引用到我的 playbook"); + await load(); + } catch (e) { + setMsg(e instanceof Error ? e.message : "fail"); + } finally { + setBusy(""); + } + } + + return ( + <> + + {msg ?

{msg}

: null} +
+
+ + setNiche(e.target.value)} placeholder="利基(保養/母嬰…)" /> + + +
+ {showPub ? ( + +
+ + setForm((f) => ({ ...f, title: e.target.value }))} + /> + setForm((f) => ({ ...f, niche: e.target.value }))} + /> +