diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d3a7c2e..5e91720 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,11 +44,11 @@ jobs: echo "=== GIT STATUS ===" git log --oneline -5 || echo "No git history available" - # Try to get previous version from git history + # Try to get previous version from git history (use HEAD^ to get the parent commit) PREVIOUS_VERSION="" echo "=== CHECKING PREVIOUS PACKAGE.JSON ===" - if git show origin/main:package.json >/dev/null 2>&1; then - PREVIOUS_VERSION=$(git show origin/main:package.json | node -p "JSON.parse(require('fs').readFileSync(0)).version") + if git show HEAD^:package.json >/dev/null 2>&1; then + PREVIOUS_VERSION=$(git show HEAD^:package.json | node -p "JSON.parse(require('fs').readFileSync(0)).version") echo "Previous version: $PREVIOUS_VERSION" else echo "No previous version found in git history" diff --git a/apps/admin/package.json b/apps/admin/package.json index 58d0a01..636e57a 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,7 +1,7 @@ { "name": "admin", "private": true, - "version": "0.0.14", + "version": "0.0.15", "type": "module", "scripts": { "dev": "vite", diff --git a/apps/admin/src/components/sidebar.tsx b/apps/admin/src/components/sidebar.tsx index 2f3e46c..bf7794c 100644 --- a/apps/admin/src/components/sidebar.tsx +++ b/apps/admin/src/components/sidebar.tsx @@ -10,6 +10,7 @@ import { Key, Link as LinkIcon, LogOut, + Mail, Settings, UserCheck, Users, @@ -104,6 +105,11 @@ export const Sidebar = () => { label="Invite Codes" to="/invite-codes" /> + } + label="Broadcast" + to="/broadcast" + /> & { id?: string }; + onSave: (data: BroadcastCreate | BroadcastUpdate) => Promise; + onCancel?: () => void; + loading?: boolean; + isEdit?: boolean; +} + +export const BroadcastEditor = ({ + broadcast, + onSave, + onCancel, + loading, + isEdit = false, +}: BroadcastEditorProps) => { + const [subject, setSubject] = useState(broadcast?.subject || ""); + const [content, setContent] = useState(broadcast?.content || ""); + const [recipientType, setRecipientType] = useState( + broadcast?.recipient_type || "all_users", + ); + const [channelId, setChannelId] = useState(broadcast?.channel_id || ""); + const [showPreview, setShowPreview] = useState(false); + const recipientTypeId = useId(); + const channelSelectId = useId(); + + const { data: channels } = useQuery({ + queryKey: ["channels"], + queryFn: () => api.channels.getAll(), + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!subject.trim() || !content.trim()) { + return; + } + + if (recipientType === "channel_members" && !channelId) { + return; + } + + if (isEdit && broadcast?.id) { + const updateData: BroadcastUpdate = { + subject: subject.trim(), + content: content.trim(), + recipient_type: recipientType, + channel_id: recipientType === "channel_members" ? channelId : undefined, + }; + await onSave(updateData); + } else { + const createData: BroadcastCreate = { + subject: subject.trim(), + content: content.trim(), + recipient_type: recipientType, + channel_id: recipientType === "channel_members" ? channelId : undefined, + }; + await onSave(createData); + } + }; + + return ( +
+
+

+ {isEdit ? "Edit Broadcast" : "Create New Broadcast"} +

+
+ {onCancel && ( + + )} + +
+
+ +
+
+
+ + setSubject(e.target.value)} + placeholder="Enter email subject..." + required + /> +
+ +
+
+ + +
+ {showPreview ? ( +
+ +
+ ) : ( +
+ setContent(val || "")} + height={400} + preview="edit" + hideToolbar={false} + visibleDragbar={false} + /> +
+ )} +
+
+ +
+
+

Recipients

+
+
+ + +
+ + {recipientType === "channel_members" && ( +
+ + +
+ )} +
+
+ +
+

Tips

+
    +
  • - Use Markdown for formatting
  • +
  • - Preview before sending
  • +
  • - Test with a single email first
  • +
+
+
+
+
+ ); +}; diff --git a/apps/admin/src/features/broadcast/components/broadcastList.tsx b/apps/admin/src/features/broadcast/components/broadcastList.tsx new file mode 100644 index 0000000..c2639f8 --- /dev/null +++ b/apps/admin/src/features/broadcast/components/broadcastList.tsx @@ -0,0 +1,280 @@ +import { Badge, Button, Input } from "@opencircle/ui"; +import { Link } from "@tanstack/react-router"; +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { format } from "date-fns"; +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + Edit, + Eye, + Search, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import type { Broadcast, BroadcastStatus } from "../utils/types"; +import { TableSkeleton } from "./tableSkeleton"; + +interface BroadcastListProps { + broadcasts: Broadcast[]; + loading?: boolean; +} + +const statusColors: Record = { + draft: "bg-yellow-500/20 text-yellow-500", + sending: "bg-blue-500/20 text-blue-500", + sent: "bg-green-500/20 text-green-500", + failed: "bg-red-500/20 text-red-500", +}; + +export const BroadcastList = ({ broadcasts, loading }: BroadcastListProps) => { + const [sorting, setSorting] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + + const columns: ColumnDef[] = [ + { + accessorKey: "subject", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => ( +
+ {row.getValue("subject")} +
+ ), + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => { + const status = row.getValue("status") as BroadcastStatus; + return ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); + }, + }, + { + accessorKey: "recipient_type", + header: "Recipients", + cell: ({ row }) => { + const broadcast = row.original; + const type = broadcast.recipient_type; + if (type === "channel_members") { + return ( +
+ {broadcast.channel?.emoji} {broadcast.channel?.name || "Channel"} +
+ ); + } + return ( +
+ {type === "all_users" ? "All Users" : "Test Email"} +
+ ); + }, + }, + { + accessorKey: "sent_count", + header: "Sent", + cell: ({ row }) => { + const broadcast = row.original; + return ( +
+ {broadcast.sent_count > 0 || broadcast.failed_count > 0 ? ( + + {broadcast.sent_count} /{" "} + {broadcast.sent_count + broadcast.failed_count} + + ) : ( + "-" + )} +
+ ); + }, + }, + { + accessorKey: "created_at", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const dateValue = row.getValue("created_at") as string; + if (!dateValue) return
N/A
; + const date = new Date(dateValue); + return ( +
+ {Number.isNaN(date.getTime()) + ? "Invalid date" + : format(date, "MMM dd, yyyy")} +
+ ); + }, + }, + { + id: "actions", + header: "Actions", + cell: ({ row }) => { + const broadcast = row.original; + return ( +
+ + + + {broadcast.status === "draft" && ( + + + + )} +
+ ); + }, + }, + ]; + + const filteredBroadcasts = useMemo(() => { + if (!searchQuery.trim()) return broadcasts; + + const query = searchQuery.toLowerCase(); + return broadcasts.filter((broadcast) => { + return broadcast.subject?.toLowerCase().includes(query); + }); + }, [broadcasts, searchQuery]); + + const table = useReactTable({ + data: filteredBroadcasts, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onSortingChange: setSorting, + state: { + sorting, + }, + }); + + if (loading) { + return ; + } + + return ( +
+
+
+ +
+ setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ + {searchQuery && ( +
+ Showing {filteredBroadcasts.length} of {broadcasts.length} broadcasts +
+ )} + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + ) : ( + + + + )} + +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+ No broadcasts found. +
+
+
+
+ ); +}; diff --git a/apps/admin/src/features/broadcast/components/broadcastView.tsx b/apps/admin/src/features/broadcast/components/broadcastView.tsx new file mode 100644 index 0000000..ead5567 --- /dev/null +++ b/apps/admin/src/features/broadcast/components/broadcastView.tsx @@ -0,0 +1,148 @@ +import { Badge, Button, Input } from "@opencircle/ui"; +import MDEditor from "@uiw/react-md-editor"; +import { format } from "date-fns"; +import { Hash, Mail, Send, Users } from "lucide-react"; +import { useState } from "react"; +import type { Broadcast, BroadcastStatus } from "../utils/types"; + +interface BroadcastViewProps { + broadcast: Broadcast; + onSendTest: (testEmail: string) => Promise; + onSend: () => Promise; + isSendingTest?: boolean; + isSending?: boolean; +} + +const statusColors: Record = { + draft: "bg-yellow-500/20 text-yellow-500", + sending: "bg-blue-500/20 text-blue-500", + sent: "bg-green-500/20 text-green-500", + failed: "bg-red-500/20 text-red-500", +}; + +export const BroadcastView = ({ + broadcast, + onSendTest, + onSend, + isSendingTest, + isSending, +}: BroadcastViewProps) => { + const [testEmail, setTestEmail] = useState(""); + + const handleSendTest = async () => { + if (!testEmail.trim()) return; + await onSendTest(testEmail.trim()); + setTestEmail(""); + }; + + return ( +
+
+
+

{broadcast.subject}

+
+ + {broadcast.status.charAt(0).toUpperCase() + + broadcast.status.slice(1)} + + + Created {format(new Date(broadcast.created_at), "MMM dd, yyyy")} + + {broadcast.sent_at && ( + + Sent {format(new Date(broadcast.sent_at), "MMM dd, yyyy HH:mm")} + + )} +
+
+
+ +
+
+
+

Email Content

+
+ +
+
+
+ +
+ {broadcast.status === "draft" && ( + <> +
+

+ + Send Test Email +

+
+ setTestEmail(e.target.value)} + /> + +
+
+ +
+

+ {broadcast.recipient_type === "channel_members" ? ( + + ) : ( + + )} + {broadcast.recipient_type === "channel_members" + ? `Send to ${broadcast.channel?.name || "Channel"} Members` + : "Send to All Users"} +

+ +

+ {broadcast.recipient_type === "channel_members" + ? `This will send the broadcast to all members of ${broadcast.channel?.emoji || ""} ${broadcast.channel?.name || "the selected channel"}.` + : "This will send the broadcast to all active users."} +

+
+ + )} + + {(broadcast.sent_count > 0 || broadcast.failed_count > 0) && ( +
+

Delivery Stats

+
+
+ Sent + + {broadcast.sent_count} + +
+
+ Failed + + {broadcast.failed_count} + +
+
+
+ )} +
+
+
+ ); +}; diff --git a/apps/admin/src/features/broadcast/components/tableSkeleton.tsx b/apps/admin/src/features/broadcast/components/tableSkeleton.tsx new file mode 100644 index 0000000..a44ab39 --- /dev/null +++ b/apps/admin/src/features/broadcast/components/tableSkeleton.tsx @@ -0,0 +1,37 @@ +interface TableSkeletonProps { + rowCount?: number; +} + +export const TableSkeleton = ({ rowCount = 5 }: TableSkeletonProps) => { + return ( +
+
+ + + + {[1, 2, 3, 4, 5].map((col) => ( + + ))} + + + + {Array.from({ length: rowCount }).map((_, rowIndex) => ( + + {[1, 2, 3, 4, 5].map((col) => ( + + ))} + + ))} + +
+
+
+
+
+
+
+ ); +}; diff --git a/apps/admin/src/features/broadcast/hooks/useBroadcastSubmission.ts b/apps/admin/src/features/broadcast/hooks/useBroadcastSubmission.ts new file mode 100644 index 0000000..a79fc94 --- /dev/null +++ b/apps/admin/src/features/broadcast/hooks/useBroadcastSubmission.ts @@ -0,0 +1,83 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "../../../utils/api"; +import type { BroadcastCreate, BroadcastUpdate } from "../utils/types"; + +export const useBroadcastSubmission = () => { + const queryClient = useQueryClient(); + + const { mutate: createBroadcast, isPending: isCreating } = useMutation({ + mutationFn: async (data: BroadcastCreate) => { + const response = await api.broadcasts.create(data); + return response; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["broadcasts"] }); + }, + }); + + const { mutate: updateBroadcast, isPending: isUpdating } = useMutation({ + mutationFn: async ({ id, data }: { id: string; data: BroadcastUpdate }) => { + const response = await api.broadcasts.update(id, data); + return response; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["broadcasts"] }); + queryClient.invalidateQueries({ queryKey: ["broadcast"] }); + }, + }); + + const { mutate: deleteBroadcast, isPending: isDeleting } = useMutation({ + mutationFn: async (broadcastId: string) => { + const response = await api.broadcasts.delete(broadcastId); + return response; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["broadcasts"] }); + }, + }); + + const { mutate: sendTestBroadcast, isPending: isSendingTest } = useMutation({ + mutationFn: async ({ + id, + testEmail, + }: { + id: string; + testEmail: string; + }) => { + const response = await api.broadcasts.sendTest(id, { + test_email: testEmail, + }); + return response; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["broadcasts"] }); + queryClient.invalidateQueries({ queryKey: ["broadcast"] }); + }, + }); + + const { mutate: sendBroadcast, isPending: isSending } = useMutation({ + mutationFn: async (broadcastId: string) => { + const response = await api.broadcasts.send(broadcastId); + return response; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["broadcasts"] }); + queryClient.invalidateQueries({ queryKey: ["broadcast"] }); + }, + }); + + return { + createBroadcast, + updateBroadcast, + deleteBroadcast, + sendTestBroadcast, + sendBroadcast, + isSubmitting: + isCreating || isUpdating || isDeleting || isSendingTest || isSending, + isCreating, + isUpdating, + isDeleting, + isSendingTest, + isSending, + }; +}; diff --git a/apps/admin/src/features/broadcast/hooks/useBroadcasts.ts b/apps/admin/src/features/broadcast/hooks/useBroadcasts.ts new file mode 100644 index 0000000..987f3ce --- /dev/null +++ b/apps/admin/src/features/broadcast/hooks/useBroadcasts.ts @@ -0,0 +1,23 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "../../../utils/api"; + +export const useBroadcasts = () => { + return useQuery({ + queryKey: ["broadcasts"], + queryFn: async () => { + const response = await api.broadcasts.getAll(); + return response; + }, + }); +}; + +export const useBroadcast = (id: string) => { + return useQuery({ + queryKey: ["broadcast", id], + queryFn: async () => { + const response = await api.broadcasts.getById(id); + return response; + }, + enabled: !!id, + }); +}; diff --git a/apps/admin/src/features/broadcast/utils/types.ts b/apps/admin/src/features/broadcast/utils/types.ts new file mode 100644 index 0000000..75f5870 --- /dev/null +++ b/apps/admin/src/features/broadcast/utils/types.ts @@ -0,0 +1,51 @@ +export type BroadcastStatus = "draft" | "sending" | "sent" | "failed"; +export type BroadcastRecipientType = + | "all_users" + | "test_email" + | "channel_members"; + +export interface Channel { + id: string; + name: string; + slug: string; + emoji?: string; +} + +export interface Broadcast { + id: string; + subject: string; + content: string; + recipient_type: BroadcastRecipientType; + test_email?: string; + channel_id?: string; + channel?: Channel; + status: BroadcastStatus; + sent_at?: string; + sent_count: number; + failed_count: number; + created_by: string; + creator?: { + id: string; + username: string; + name?: string; + avatar_url?: string; + }; + created_at: string; + updated_at: string; +} + +export interface BroadcastCreate { + subject: string; + content: string; + recipient_type?: BroadcastRecipientType; + test_email?: string; + channel_id?: string; +} + +export interface BroadcastUpdate { + subject?: string; + content?: string; + recipient_type?: BroadcastRecipientType; + test_email?: string; + channel_id?: string; +} diff --git a/apps/admin/src/routeTree.gen.ts b/apps/admin/src/routeTree.gen.ts index a5b2e5b..738a697 100644 --- a/apps/admin/src/routeTree.gen.ts +++ b/apps/admin/src/routeTree.gen.ts @@ -11,7 +11,6 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as DashboardLayoutRouteImport } from './routes/_dashboardLayout' import { Route as IndexRouteImport } from './routes/index' -import { Route as DashboardLayoutBroadcastRouteImport } from './routes/_dashboardLayout/broadcast' import { Route as DashboardLayoutUsersIndexRouteImport } from './routes/_dashboardLayout/users/index' import { Route as DashboardLayoutResourcesIndexRouteImport } from './routes/_dashboardLayout/resources/index' import { Route as DashboardLayoutInviteCodesIndexRouteImport } from './routes/_dashboardLayout/invite-codes/index' @@ -19,6 +18,7 @@ import { Route as DashboardLayoutEnrollmentsIndexRouteImport } from './routes/_d import { Route as DashboardLayoutDashboardIndexRouteImport } from './routes/_dashboardLayout/dashboard/index' import { Route as DashboardLayoutCoursesIndexRouteImport } from './routes/_dashboardLayout/courses/index' import { Route as DashboardLayoutChannelsIndexRouteImport } from './routes/_dashboardLayout/channels/index' +import { Route as DashboardLayoutBroadcastIndexRouteImport } from './routes/_dashboardLayout/broadcast/index' import { Route as DashboardLayoutArticlesIndexRouteImport } from './routes/_dashboardLayout/articles/index' import { Route as DashboardLayoutAppSettingsIndexRouteImport } from './routes/_dashboardLayout/app-settings/index' import { Route as DashboardLayoutAppLinksIndexRouteImport } from './routes/_dashboardLayout/app-links/index' @@ -28,11 +28,14 @@ import { Route as DashboardLayoutInviteCodesNewRouteImport } from './routes/_das import { Route as DashboardLayoutInviteCodesIdRouteImport } from './routes/_dashboardLayout/invite-codes/$id' import { Route as DashboardLayoutCoursesNewRouteImport } from './routes/_dashboardLayout/courses/new' import { Route as DashboardLayoutChannelsIdRouteImport } from './routes/_dashboardLayout/channels/$id' +import { Route as DashboardLayoutBroadcastNewRouteImport } from './routes/_dashboardLayout/broadcast/new' +import { Route as DashboardLayoutBroadcastIdRouteImport } from './routes/_dashboardLayout/broadcast/$id' import { Route as DashboardLayoutArticlesNewRouteImport } from './routes/_dashboardLayout/articles/new' import { Route as DashboardLayoutArticlesIdRouteImport } from './routes/_dashboardLayout/articles/$id' import { Route as DashboardLayoutInviteCodesEditIdRouteImport } from './routes/_dashboardLayout/invite-codes/edit.$id' import { Route as DashboardLayoutCoursesEditIdRouteImport } from './routes/_dashboardLayout/courses/edit.$id' import { Route as DashboardLayoutChannelsEditIdRouteImport } from './routes/_dashboardLayout/channels/edit.$id' +import { Route as DashboardLayoutBroadcastEditIdRouteImport } from './routes/_dashboardLayout/broadcast/edit.$id' import { Route as DashboardLayoutArticlesEditIdRouteImport } from './routes/_dashboardLayout/articles/edit.$id' import { Route as DashboardLayoutCoursesSectionsSectionIdEditRouteImport } from './routes/_dashboardLayout/courses/sections/$sectionId/edit' import { Route as DashboardLayoutCoursesLessonsLessonIdEditRouteImport } from './routes/_dashboardLayout/courses/lessons/$lessonId/edit' @@ -46,12 +49,6 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) -const DashboardLayoutBroadcastRoute = - DashboardLayoutBroadcastRouteImport.update({ - id: '/broadcast', - path: '/broadcast', - getParentRoute: () => DashboardLayoutRoute, - } as any) const DashboardLayoutUsersIndexRoute = DashboardLayoutUsersIndexRouteImport.update({ id: '/users/', @@ -94,6 +91,12 @@ const DashboardLayoutChannelsIndexRoute = path: '/channels/', getParentRoute: () => DashboardLayoutRoute, } as any) +const DashboardLayoutBroadcastIndexRoute = + DashboardLayoutBroadcastIndexRouteImport.update({ + id: '/broadcast/', + path: '/broadcast/', + getParentRoute: () => DashboardLayoutRoute, + } as any) const DashboardLayoutArticlesIndexRoute = DashboardLayoutArticlesIndexRouteImport.update({ id: '/articles/', @@ -147,6 +150,18 @@ const DashboardLayoutChannelsIdRoute = path: '/channels/$id', getParentRoute: () => DashboardLayoutRoute, } as any) +const DashboardLayoutBroadcastNewRoute = + DashboardLayoutBroadcastNewRouteImport.update({ + id: '/broadcast/new', + path: '/broadcast/new', + getParentRoute: () => DashboardLayoutRoute, + } as any) +const DashboardLayoutBroadcastIdRoute = + DashboardLayoutBroadcastIdRouteImport.update({ + id: '/broadcast/$id', + path: '/broadcast/$id', + getParentRoute: () => DashboardLayoutRoute, + } as any) const DashboardLayoutArticlesNewRoute = DashboardLayoutArticlesNewRouteImport.update({ id: '/articles/new', @@ -177,6 +192,12 @@ const DashboardLayoutChannelsEditIdRoute = path: '/channels/edit/$id', getParentRoute: () => DashboardLayoutRoute, } as any) +const DashboardLayoutBroadcastEditIdRoute = + DashboardLayoutBroadcastEditIdRouteImport.update({ + id: '/broadcast/edit/$id', + path: '/broadcast/edit/$id', + getParentRoute: () => DashboardLayoutRoute, + } as any) const DashboardLayoutArticlesEditIdRoute = DashboardLayoutArticlesEditIdRouteImport.update({ id: '/articles/edit/$id', @@ -198,9 +219,10 @@ const DashboardLayoutCoursesLessonsLessonIdEditRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/broadcast': typeof DashboardLayoutBroadcastRoute '/articles/$id': typeof DashboardLayoutArticlesIdRoute '/articles/new': typeof DashboardLayoutArticlesNewRoute + '/broadcast/$id': typeof DashboardLayoutBroadcastIdRoute + '/broadcast/new': typeof DashboardLayoutBroadcastNewRoute '/channels/$id': typeof DashboardLayoutChannelsIdRoute '/courses/new': typeof DashboardLayoutCoursesNewRoute '/invite-codes/$id': typeof DashboardLayoutInviteCodesIdRoute @@ -210,6 +232,7 @@ export interface FileRoutesByFullPath { '/app-links': typeof DashboardLayoutAppLinksIndexRoute '/app-settings': typeof DashboardLayoutAppSettingsIndexRoute '/articles': typeof DashboardLayoutArticlesIndexRoute + '/broadcast': typeof DashboardLayoutBroadcastIndexRoute '/channels': typeof DashboardLayoutChannelsIndexRoute '/courses': typeof DashboardLayoutCoursesIndexRoute '/dashboard': typeof DashboardLayoutDashboardIndexRoute @@ -218,6 +241,7 @@ export interface FileRoutesByFullPath { '/resources': typeof DashboardLayoutResourcesIndexRoute '/users': typeof DashboardLayoutUsersIndexRoute '/articles/edit/$id': typeof DashboardLayoutArticlesEditIdRoute + '/broadcast/edit/$id': typeof DashboardLayoutBroadcastEditIdRoute '/channels/edit/$id': typeof DashboardLayoutChannelsEditIdRoute '/courses/edit/$id': typeof DashboardLayoutCoursesEditIdRoute '/invite-codes/edit/$id': typeof DashboardLayoutInviteCodesEditIdRoute @@ -226,9 +250,10 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute - '/broadcast': typeof DashboardLayoutBroadcastRoute '/articles/$id': typeof DashboardLayoutArticlesIdRoute '/articles/new': typeof DashboardLayoutArticlesNewRoute + '/broadcast/$id': typeof DashboardLayoutBroadcastIdRoute + '/broadcast/new': typeof DashboardLayoutBroadcastNewRoute '/channels/$id': typeof DashboardLayoutChannelsIdRoute '/courses/new': typeof DashboardLayoutCoursesNewRoute '/invite-codes/$id': typeof DashboardLayoutInviteCodesIdRoute @@ -238,6 +263,7 @@ export interface FileRoutesByTo { '/app-links': typeof DashboardLayoutAppLinksIndexRoute '/app-settings': typeof DashboardLayoutAppSettingsIndexRoute '/articles': typeof DashboardLayoutArticlesIndexRoute + '/broadcast': typeof DashboardLayoutBroadcastIndexRoute '/channels': typeof DashboardLayoutChannelsIndexRoute '/courses': typeof DashboardLayoutCoursesIndexRoute '/dashboard': typeof DashboardLayoutDashboardIndexRoute @@ -246,6 +272,7 @@ export interface FileRoutesByTo { '/resources': typeof DashboardLayoutResourcesIndexRoute '/users': typeof DashboardLayoutUsersIndexRoute '/articles/edit/$id': typeof DashboardLayoutArticlesEditIdRoute + '/broadcast/edit/$id': typeof DashboardLayoutBroadcastEditIdRoute '/channels/edit/$id': typeof DashboardLayoutChannelsEditIdRoute '/courses/edit/$id': typeof DashboardLayoutCoursesEditIdRoute '/invite-codes/edit/$id': typeof DashboardLayoutInviteCodesEditIdRoute @@ -256,9 +283,10 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/_dashboardLayout': typeof DashboardLayoutRouteWithChildren - '/_dashboardLayout/broadcast': typeof DashboardLayoutBroadcastRoute '/_dashboardLayout/articles/$id': typeof DashboardLayoutArticlesIdRoute '/_dashboardLayout/articles/new': typeof DashboardLayoutArticlesNewRoute + '/_dashboardLayout/broadcast/$id': typeof DashboardLayoutBroadcastIdRoute + '/_dashboardLayout/broadcast/new': typeof DashboardLayoutBroadcastNewRoute '/_dashboardLayout/channels/$id': typeof DashboardLayoutChannelsIdRoute '/_dashboardLayout/courses/new': typeof DashboardLayoutCoursesNewRoute '/_dashboardLayout/invite-codes/$id': typeof DashboardLayoutInviteCodesIdRoute @@ -268,6 +296,7 @@ export interface FileRoutesById { '/_dashboardLayout/app-links/': typeof DashboardLayoutAppLinksIndexRoute '/_dashboardLayout/app-settings/': typeof DashboardLayoutAppSettingsIndexRoute '/_dashboardLayout/articles/': typeof DashboardLayoutArticlesIndexRoute + '/_dashboardLayout/broadcast/': typeof DashboardLayoutBroadcastIndexRoute '/_dashboardLayout/channels/': typeof DashboardLayoutChannelsIndexRoute '/_dashboardLayout/courses/': typeof DashboardLayoutCoursesIndexRoute '/_dashboardLayout/dashboard/': typeof DashboardLayoutDashboardIndexRoute @@ -276,6 +305,7 @@ export interface FileRoutesById { '/_dashboardLayout/resources/': typeof DashboardLayoutResourcesIndexRoute '/_dashboardLayout/users/': typeof DashboardLayoutUsersIndexRoute '/_dashboardLayout/articles/edit/$id': typeof DashboardLayoutArticlesEditIdRoute + '/_dashboardLayout/broadcast/edit/$id': typeof DashboardLayoutBroadcastEditIdRoute '/_dashboardLayout/channels/edit/$id': typeof DashboardLayoutChannelsEditIdRoute '/_dashboardLayout/courses/edit/$id': typeof DashboardLayoutCoursesEditIdRoute '/_dashboardLayout/invite-codes/edit/$id': typeof DashboardLayoutInviteCodesEditIdRoute @@ -286,9 +316,10 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' - | '/broadcast' | '/articles/$id' | '/articles/new' + | '/broadcast/$id' + | '/broadcast/new' | '/channels/$id' | '/courses/new' | '/invite-codes/$id' @@ -298,6 +329,7 @@ export interface FileRouteTypes { | '/app-links' | '/app-settings' | '/articles' + | '/broadcast' | '/channels' | '/courses' | '/dashboard' @@ -306,6 +338,7 @@ export interface FileRouteTypes { | '/resources' | '/users' | '/articles/edit/$id' + | '/broadcast/edit/$id' | '/channels/edit/$id' | '/courses/edit/$id' | '/invite-codes/edit/$id' @@ -314,9 +347,10 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' - | '/broadcast' | '/articles/$id' | '/articles/new' + | '/broadcast/$id' + | '/broadcast/new' | '/channels/$id' | '/courses/new' | '/invite-codes/$id' @@ -326,6 +360,7 @@ export interface FileRouteTypes { | '/app-links' | '/app-settings' | '/articles' + | '/broadcast' | '/channels' | '/courses' | '/dashboard' @@ -334,6 +369,7 @@ export interface FileRouteTypes { | '/resources' | '/users' | '/articles/edit/$id' + | '/broadcast/edit/$id' | '/channels/edit/$id' | '/courses/edit/$id' | '/invite-codes/edit/$id' @@ -343,9 +379,10 @@ export interface FileRouteTypes { | '__root__' | '/' | '/_dashboardLayout' - | '/_dashboardLayout/broadcast' | '/_dashboardLayout/articles/$id' | '/_dashboardLayout/articles/new' + | '/_dashboardLayout/broadcast/$id' + | '/_dashboardLayout/broadcast/new' | '/_dashboardLayout/channels/$id' | '/_dashboardLayout/courses/new' | '/_dashboardLayout/invite-codes/$id' @@ -355,6 +392,7 @@ export interface FileRouteTypes { | '/_dashboardLayout/app-links/' | '/_dashboardLayout/app-settings/' | '/_dashboardLayout/articles/' + | '/_dashboardLayout/broadcast/' | '/_dashboardLayout/channels/' | '/_dashboardLayout/courses/' | '/_dashboardLayout/dashboard/' @@ -363,6 +401,7 @@ export interface FileRouteTypes { | '/_dashboardLayout/resources/' | '/_dashboardLayout/users/' | '/_dashboardLayout/articles/edit/$id' + | '/_dashboardLayout/broadcast/edit/$id' | '/_dashboardLayout/channels/edit/$id' | '/_dashboardLayout/courses/edit/$id' | '/_dashboardLayout/invite-codes/edit/$id' @@ -391,13 +430,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } - '/_dashboardLayout/broadcast': { - id: '/_dashboardLayout/broadcast' - path: '/broadcast' - fullPath: '/broadcast' - preLoaderRoute: typeof DashboardLayoutBroadcastRouteImport - parentRoute: typeof DashboardLayoutRoute - } '/_dashboardLayout/users/': { id: '/_dashboardLayout/users/' path: '/users' @@ -447,6 +479,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardLayoutChannelsIndexRouteImport parentRoute: typeof DashboardLayoutRoute } + '/_dashboardLayout/broadcast/': { + id: '/_dashboardLayout/broadcast/' + path: '/broadcast' + fullPath: '/broadcast' + preLoaderRoute: typeof DashboardLayoutBroadcastIndexRouteImport + parentRoute: typeof DashboardLayoutRoute + } '/_dashboardLayout/articles/': { id: '/_dashboardLayout/articles/' path: '/articles' @@ -510,6 +549,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardLayoutChannelsIdRouteImport parentRoute: typeof DashboardLayoutRoute } + '/_dashboardLayout/broadcast/new': { + id: '/_dashboardLayout/broadcast/new' + path: '/broadcast/new' + fullPath: '/broadcast/new' + preLoaderRoute: typeof DashboardLayoutBroadcastNewRouteImport + parentRoute: typeof DashboardLayoutRoute + } + '/_dashboardLayout/broadcast/$id': { + id: '/_dashboardLayout/broadcast/$id' + path: '/broadcast/$id' + fullPath: '/broadcast/$id' + preLoaderRoute: typeof DashboardLayoutBroadcastIdRouteImport + parentRoute: typeof DashboardLayoutRoute + } '/_dashboardLayout/articles/new': { id: '/_dashboardLayout/articles/new' path: '/articles/new' @@ -545,6 +598,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardLayoutChannelsEditIdRouteImport parentRoute: typeof DashboardLayoutRoute } + '/_dashboardLayout/broadcast/edit/$id': { + id: '/_dashboardLayout/broadcast/edit/$id' + path: '/broadcast/edit/$id' + fullPath: '/broadcast/edit/$id' + preLoaderRoute: typeof DashboardLayoutBroadcastEditIdRouteImport + parentRoute: typeof DashboardLayoutRoute + } '/_dashboardLayout/articles/edit/$id': { id: '/_dashboardLayout/articles/edit/$id' path: '/articles/edit/$id' @@ -570,9 +630,10 @@ declare module '@tanstack/react-router' { } interface DashboardLayoutRouteChildren { - DashboardLayoutBroadcastRoute: typeof DashboardLayoutBroadcastRoute DashboardLayoutArticlesIdRoute: typeof DashboardLayoutArticlesIdRoute DashboardLayoutArticlesNewRoute: typeof DashboardLayoutArticlesNewRoute + DashboardLayoutBroadcastIdRoute: typeof DashboardLayoutBroadcastIdRoute + DashboardLayoutBroadcastNewRoute: typeof DashboardLayoutBroadcastNewRoute DashboardLayoutChannelsIdRoute: typeof DashboardLayoutChannelsIdRoute DashboardLayoutCoursesNewRoute: typeof DashboardLayoutCoursesNewRoute DashboardLayoutInviteCodesIdRoute: typeof DashboardLayoutInviteCodesIdRoute @@ -582,6 +643,7 @@ interface DashboardLayoutRouteChildren { DashboardLayoutAppLinksIndexRoute: typeof DashboardLayoutAppLinksIndexRoute DashboardLayoutAppSettingsIndexRoute: typeof DashboardLayoutAppSettingsIndexRoute DashboardLayoutArticlesIndexRoute: typeof DashboardLayoutArticlesIndexRoute + DashboardLayoutBroadcastIndexRoute: typeof DashboardLayoutBroadcastIndexRoute DashboardLayoutChannelsIndexRoute: typeof DashboardLayoutChannelsIndexRoute DashboardLayoutCoursesIndexRoute: typeof DashboardLayoutCoursesIndexRoute DashboardLayoutDashboardIndexRoute: typeof DashboardLayoutDashboardIndexRoute @@ -590,6 +652,7 @@ interface DashboardLayoutRouteChildren { DashboardLayoutResourcesIndexRoute: typeof DashboardLayoutResourcesIndexRoute DashboardLayoutUsersIndexRoute: typeof DashboardLayoutUsersIndexRoute DashboardLayoutArticlesEditIdRoute: typeof DashboardLayoutArticlesEditIdRoute + DashboardLayoutBroadcastEditIdRoute: typeof DashboardLayoutBroadcastEditIdRoute DashboardLayoutChannelsEditIdRoute: typeof DashboardLayoutChannelsEditIdRoute DashboardLayoutCoursesEditIdRoute: typeof DashboardLayoutCoursesEditIdRoute DashboardLayoutInviteCodesEditIdRoute: typeof DashboardLayoutInviteCodesEditIdRoute @@ -598,9 +661,10 @@ interface DashboardLayoutRouteChildren { } const DashboardLayoutRouteChildren: DashboardLayoutRouteChildren = { - DashboardLayoutBroadcastRoute: DashboardLayoutBroadcastRoute, DashboardLayoutArticlesIdRoute: DashboardLayoutArticlesIdRoute, DashboardLayoutArticlesNewRoute: DashboardLayoutArticlesNewRoute, + DashboardLayoutBroadcastIdRoute: DashboardLayoutBroadcastIdRoute, + DashboardLayoutBroadcastNewRoute: DashboardLayoutBroadcastNewRoute, DashboardLayoutChannelsIdRoute: DashboardLayoutChannelsIdRoute, DashboardLayoutCoursesNewRoute: DashboardLayoutCoursesNewRoute, DashboardLayoutInviteCodesIdRoute: DashboardLayoutInviteCodesIdRoute, @@ -610,6 +674,7 @@ const DashboardLayoutRouteChildren: DashboardLayoutRouteChildren = { DashboardLayoutAppLinksIndexRoute: DashboardLayoutAppLinksIndexRoute, DashboardLayoutAppSettingsIndexRoute: DashboardLayoutAppSettingsIndexRoute, DashboardLayoutArticlesIndexRoute: DashboardLayoutArticlesIndexRoute, + DashboardLayoutBroadcastIndexRoute: DashboardLayoutBroadcastIndexRoute, DashboardLayoutChannelsIndexRoute: DashboardLayoutChannelsIndexRoute, DashboardLayoutCoursesIndexRoute: DashboardLayoutCoursesIndexRoute, DashboardLayoutDashboardIndexRoute: DashboardLayoutDashboardIndexRoute, @@ -618,6 +683,7 @@ const DashboardLayoutRouteChildren: DashboardLayoutRouteChildren = { DashboardLayoutResourcesIndexRoute: DashboardLayoutResourcesIndexRoute, DashboardLayoutUsersIndexRoute: DashboardLayoutUsersIndexRoute, DashboardLayoutArticlesEditIdRoute: DashboardLayoutArticlesEditIdRoute, + DashboardLayoutBroadcastEditIdRoute: DashboardLayoutBroadcastEditIdRoute, DashboardLayoutChannelsEditIdRoute: DashboardLayoutChannelsEditIdRoute, DashboardLayoutCoursesEditIdRoute: DashboardLayoutCoursesEditIdRoute, DashboardLayoutInviteCodesEditIdRoute: DashboardLayoutInviteCodesEditIdRoute, diff --git a/apps/admin/src/routes/_dashboardLayout/broadcast.tsx b/apps/admin/src/routes/_dashboardLayout/broadcast.tsx deleted file mode 100644 index 93c8f5a..0000000 --- a/apps/admin/src/routes/_dashboardLayout/broadcast.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { METADATA } from "../../constants/metadata"; - -export const Route = createFileRoute("/_dashboardLayout/broadcast")({ - head: () => ({ - meta: [ - { - title: "Broadcast - OpenCircle Admin", - }, - { - name: "description", - content: "Broadcast messages to users on OpenCircle", - }, - ], - links: [ - { - rel: "icon", - href: METADATA.favicon, - }, - ], - }), - component: RouteComponent, -}); - -function RouteComponent() { - return
Broadcast
; -} diff --git a/apps/admin/src/routes/_dashboardLayout/broadcast/$id.tsx b/apps/admin/src/routes/_dashboardLayout/broadcast/$id.tsx new file mode 100644 index 0000000..9c7d228 --- /dev/null +++ b/apps/admin/src/routes/_dashboardLayout/broadcast/$id.tsx @@ -0,0 +1,113 @@ +import { Button } from "@opencircle/ui"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { ArrowLeft } from "lucide-react"; +import { METADATA } from "../../../constants/metadata"; +import { BroadcastView } from "../../../features/broadcast/components/broadcastView"; +import { useBroadcastSubmission } from "../../../features/broadcast/hooks/useBroadcastSubmission"; +import { useBroadcast } from "../../../features/broadcast/hooks/useBroadcasts"; + +export const Route = createFileRoute("/_dashboardLayout/broadcast/$id")({ + head: () => ({ + meta: [ + { + title: "View Broadcast - OpenCircle Admin", + }, + { + name: "description", + content: "View broadcast details", + }, + ], + links: [ + { + rel: "icon", + href: METADATA.favicon, + }, + ], + }), + component: RouteComponent, +}); + +function RouteComponent() { + const { id } = Route.useParams(); + const navigate = useNavigate(); + const { data: broadcast, isLoading } = useBroadcast(id); + const { sendTestBroadcast, sendBroadcast, isSendingTest, isSending } = + useBroadcastSubmission(); + + const handleSendTest = async (testEmail: string) => { + sendTestBroadcast( + { id, testEmail }, + { + onSuccess: () => { + alert("Test email queued successfully!"); + }, + onError: (error) => { + alert(`Failed to send test: ${error.message}`); + }, + }, + ); + }; + + const handleSend = async () => { + if ( + !window.confirm( + "Are you sure you want to send this broadcast to all users?", + ) + ) { + return; + } + + sendBroadcast(id, { + onSuccess: () => { + alert("Broadcast queued successfully!"); + navigate({ to: "/broadcast" }); + }, + onError: (error) => { + alert(`Failed to send broadcast: ${error.message}`); + }, + }); + }; + + if (isLoading) { + return ( +
+
+
Loading...
+
+
+ ); + } + + if (!broadcast) { + return ( +
+
+
Broadcast not found
+ + + +
+
+ ); + } + + return ( +
+
+ + + +
+ +
+ ); +} diff --git a/apps/admin/src/routes/_dashboardLayout/broadcast/edit.$id.tsx b/apps/admin/src/routes/_dashboardLayout/broadcast/edit.$id.tsx new file mode 100644 index 0000000..f0a53a6 --- /dev/null +++ b/apps/admin/src/routes/_dashboardLayout/broadcast/edit.$id.tsx @@ -0,0 +1,81 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { METADATA } from "../../../constants/metadata"; +import { BroadcastEditor } from "../../../features/broadcast/components/broadcastEditor"; +import { useBroadcastSubmission } from "../../../features/broadcast/hooks/useBroadcastSubmission"; +import { useBroadcast } from "../../../features/broadcast/hooks/useBroadcasts"; +import type { BroadcastUpdate } from "../../../features/broadcast/utils/types"; + +export const Route = createFileRoute("/_dashboardLayout/broadcast/edit/$id")({ + head: () => ({ + meta: [ + { + title: "Edit Broadcast - OpenCircle Admin", + }, + { + name: "description", + content: "Edit broadcast email", + }, + ], + links: [ + { + rel: "icon", + href: METADATA.favicon, + }, + ], + }), + component: RouteComponent, +}); + +function RouteComponent() { + const { id } = Route.useParams(); + const navigate = useNavigate(); + const { data: broadcast, isLoading } = useBroadcast(id); + const { updateBroadcast, isUpdating } = useBroadcastSubmission(); + + const handleSave = async (data: BroadcastUpdate) => { + updateBroadcast( + { id, data }, + { + onSuccess: () => { + navigate({ to: "/broadcast/$id", params: { id } }); + }, + }, + ); + }; + + const handleCancel = () => { + navigate({ to: "/broadcast/$id", params: { id } }); + }; + + if (isLoading) { + return ( +
+
+
Loading...
+
+
+ ); + } + + if (!broadcast) { + return ( +
+
+
Broadcast not found
+
+
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/apps/admin/src/routes/_dashboardLayout/broadcast/index.tsx b/apps/admin/src/routes/_dashboardLayout/broadcast/index.tsx new file mode 100644 index 0000000..624e53c --- /dev/null +++ b/apps/admin/src/routes/_dashboardLayout/broadcast/index.tsx @@ -0,0 +1,42 @@ +import { Button } from "@opencircle/ui"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { METADATA } from "../../../constants/metadata"; +import { BroadcastList } from "../../../features/broadcast/components/broadcastList"; +import { useBroadcasts } from "../../../features/broadcast/hooks/useBroadcasts"; + +export const Route = createFileRoute("/_dashboardLayout/broadcast/")({ + head: () => ({ + meta: [ + { + title: "Broadcasts - OpenCircle Admin", + }, + { + name: "description", + content: "Manage broadcast emails on OpenCircle", + }, + ], + links: [ + { + rel: "icon", + href: METADATA.favicon, + }, + ], + }), + component: RouteComponent, +}); + +function RouteComponent() { + const { data: broadcasts, isLoading } = useBroadcasts(); + + return ( +
+
+

Broadcasts

+ + + +
+ +
+ ); +} diff --git a/apps/admin/src/routes/_dashboardLayout/broadcast/new.tsx b/apps/admin/src/routes/_dashboardLayout/broadcast/new.tsx new file mode 100644 index 0000000..ab7da9b --- /dev/null +++ b/apps/admin/src/routes/_dashboardLayout/broadcast/new.tsx @@ -0,0 +1,56 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { METADATA } from "../../../constants/metadata"; +import { BroadcastEditor } from "../../../features/broadcast/components/broadcastEditor"; +import { useBroadcastSubmission } from "../../../features/broadcast/hooks/useBroadcastSubmission"; +import type { + BroadcastCreate, + BroadcastUpdate, +} from "../../../features/broadcast/utils/types"; + +export const Route = createFileRoute("/_dashboardLayout/broadcast/new")({ + head: () => ({ + meta: [ + { + title: "Create Broadcast - OpenCircle Admin", + }, + { + name: "description", + content: "Create a new broadcast email", + }, + ], + links: [ + { + rel: "icon", + href: METADATA.favicon, + }, + ], + }), + component: RouteComponent, +}); + +function RouteComponent() { + const navigate = useNavigate(); + const { createBroadcast, isCreating } = useBroadcastSubmission(); + + const handleSave = async (data: BroadcastCreate | BroadcastUpdate) => { + createBroadcast(data as BroadcastCreate, { + onSuccess: (broadcast) => { + navigate({ to: "/broadcast/$id", params: { id: broadcast.id } }); + }, + }); + }; + + const handleCancel = () => { + navigate({ to: "/broadcast" }); + }; + + return ( +
+ +
+ ); +} diff --git a/apps/api/alembic/versions/1053d8244b76_add_channel_members_enum_value.py b/apps/api/alembic/versions/1053d8244b76_add_channel_members_enum_value.py new file mode 100644 index 0000000..b174601 --- /dev/null +++ b/apps/api/alembic/versions/1053d8244b76_add_channel_members_enum_value.py @@ -0,0 +1,34 @@ +"""add channel_members enum value + +Revision ID: 1053d8244b76 +Revises: 232aa4832d70 +Create Date: 2025-11-28 23:09:16.186490 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + + +# revision identifiers, used by Alembic. +revision: str = '1053d8244b76' +down_revision: Union[str, Sequence[str], None] = '232aa4832d70' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.execute("ALTER TYPE broadcastrecipienttype ADD VALUE IF NOT EXISTS 'channel_members'") + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/232aa4832d70_update_broadcast_model.py b/apps/api/alembic/versions/232aa4832d70_update_broadcast_model.py new file mode 100644 index 0000000..d1d0511 --- /dev/null +++ b/apps/api/alembic/versions/232aa4832d70_update_broadcast_model.py @@ -0,0 +1,36 @@ +"""update broadcast model + +Revision ID: 232aa4832d70 +Revises: 593648ef190a +Create Date: 2025-11-28 23:02:18.949237 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + + +# revision identifiers, used by Alembic. +revision: str = '232aa4832d70' +down_revision: Union[str, Sequence[str], None] = '593648ef190a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('broadcast', sa.Column('channel_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + op.create_foreign_key(None, 'broadcast', 'channel', ['channel_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'broadcast', type_='foreignkey') + op.drop_column('broadcast', 'channel_id') + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/2c9551fa3333_fix_enum_values_to_lowercase.py b/apps/api/alembic/versions/2c9551fa3333_fix_enum_values_to_lowercase.py new file mode 100644 index 0000000..662e263 --- /dev/null +++ b/apps/api/alembic/versions/2c9551fa3333_fix_enum_values_to_lowercase.py @@ -0,0 +1,55 @@ +"""fix_enum_values_to_lowercase + +Revision ID: 2c9551fa3333 +Revises: 1053d8244b76 +Create Date: 2025-11-28 23:16:34.733476 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + + +# revision identifiers, used by Alembic. +revision: str = '2c9551fa3333' +down_revision: Union[str, Sequence[str], None] = '1053d8244b76' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Fix broadcastrecipienttype enum - rename UPPERCASE values to lowercase + # Note: 'channel_members' was already added in lowercase by previous migration + op.execute("ALTER TYPE broadcastrecipienttype RENAME VALUE 'ALL_USERS' TO 'all_users'") + op.execute("ALTER TYPE broadcastrecipienttype RENAME VALUE 'TEST_EMAIL' TO 'test_email'") + + # Fix broadcaststatus enum - rename values to lowercase + op.execute("ALTER TYPE broadcaststatus RENAME VALUE 'DRAFT' TO 'draft'") + op.execute("ALTER TYPE broadcaststatus RENAME VALUE 'SENDING' TO 'sending'") + op.execute("ALTER TYPE broadcaststatus RENAME VALUE 'SENT' TO 'sent'") + op.execute("ALTER TYPE broadcaststatus RENAME VALUE 'FAILED' TO 'failed'") + + # Fix broadcastrecipientstatus enum - rename values to lowercase + op.execute("ALTER TYPE broadcastrecipientstatus RENAME VALUE 'PENDING' TO 'pending'") + op.execute("ALTER TYPE broadcastrecipientstatus RENAME VALUE 'SENT' TO 'sent'") + op.execute("ALTER TYPE broadcastrecipientstatus RENAME VALUE 'FAILED' TO 'failed'") + + +def downgrade() -> None: + """Downgrade schema.""" + # Revert to uppercase values + op.execute("ALTER TYPE broadcastrecipienttype RENAME VALUE 'all_users' TO 'ALL_USERS'") + op.execute("ALTER TYPE broadcastrecipienttype RENAME VALUE 'test_email' TO 'TEST_EMAIL'") + + op.execute("ALTER TYPE broadcaststatus RENAME VALUE 'draft' TO 'DRAFT'") + op.execute("ALTER TYPE broadcaststatus RENAME VALUE 'sending' TO 'SENDING'") + op.execute("ALTER TYPE broadcaststatus RENAME VALUE 'sent' TO 'SENT'") + op.execute("ALTER TYPE broadcaststatus RENAME VALUE 'failed' TO 'FAILED'") + + op.execute("ALTER TYPE broadcastrecipientstatus RENAME VALUE 'pending' TO 'PENDING'") + op.execute("ALTER TYPE broadcastrecipientstatus RENAME VALUE 'sent' TO 'SENT'") + op.execute("ALTER TYPE broadcastrecipientstatus RENAME VALUE 'failed' TO 'FAILED'") diff --git a/apps/api/alembic/versions/593648ef190a_add_broadcast_model.py b/apps/api/alembic/versions/593648ef190a_add_broadcast_model.py new file mode 100644 index 0000000..dc10595 --- /dev/null +++ b/apps/api/alembic/versions/593648ef190a_add_broadcast_model.py @@ -0,0 +1,74 @@ +"""add broadcast model + +Revision ID: 593648ef190a +Revises: f843c2a188ce +Create Date: 2025-11-28 22:40:44.637736 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + + +# revision identifiers, used by Alembic. +revision: str = '593648ef190a' +down_revision: Union[str, Sequence[str], None] = 'f843c2a188ce' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('broadcast', + sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('subject', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('content', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('recipient_type', sa.Enum('ALL_USERS', 'TEST_EMAIL', name='broadcastrecipienttype'), nullable=False), + sa.Column('test_email', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('status', sa.Enum('DRAFT', 'SENDING', 'SENT', 'FAILED', name='broadcaststatus'), nullable=False), + sa.Column('sent_at', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('sent_count', sa.Integer(), nullable=False), + sa.Column('failed_count', sa.Integer(), nullable=False), + sa.Column('created_by', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.ForeignKeyConstraint(['created_by'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_broadcast_subject'), 'broadcast', ['subject'], unique=False) + op.create_table('broadcast_recipient', + sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('broadcast_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('user_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('status', sa.Enum('PENDING', 'SENT', 'FAILED', name='broadcastrecipientstatus'), nullable=False), + sa.Column('sent_at', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('error_message', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.ForeignKeyConstraint(['broadcast_id'], ['broadcast.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_broadcast_recipient_broadcast_id'), 'broadcast_recipient', ['broadcast_id'], unique=False) + op.create_index(op.f('ix_broadcast_recipient_email'), 'broadcast_recipient', ['email'], unique=False) + op.create_index(op.f('ix_broadcast_recipient_user_id'), 'broadcast_recipient', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_broadcast_recipient_user_id'), table_name='broadcast_recipient') + op.drop_index(op.f('ix_broadcast_recipient_email'), table_name='broadcast_recipient') + op.drop_index(op.f('ix_broadcast_recipient_broadcast_id'), table_name='broadcast_recipient') + op.drop_table('broadcast_recipient') + op.drop_index(op.f('ix_broadcast_subject'), table_name='broadcast') + op.drop_table('broadcast') + # ### end Alembic commands ### diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 88ca329..2d842cc 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "celery>=5.5.3", "faker>=37.11.0", "fastapi>=0.119.0", + "markdown>=3.7", "passlib>=1.7.4", "psycopg2-binary>=2.9.11", "pydantic-settings>=2.11.0", diff --git a/apps/api/src/api/broadcast/__init__.py b/apps/api/src/api/broadcast/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/api/src/api/broadcast/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/api/src/api/broadcast/api.py b/apps/api/src/api/broadcast/api.py new file mode 100644 index 0000000..74f8c7f --- /dev/null +++ b/apps/api/src/api/broadcast/api.py @@ -0,0 +1,203 @@ +from typing import List + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session + +from src.api.account.api import get_current_user +from src.api.broadcast.serializer import ( + BroadcastCreate, + BroadcastResponse, + BroadcastSendTest, + BroadcastUpdate, +) +from src.database.engine import get_session +from src.database.models import BroadcastRecipientType, BroadcastStatus, Role, User +from src.modules.broadcast.broadcast_methods import ( + create_broadcast, + create_broadcast_recipients, + delete_broadcast, + get_all_active_users, + get_all_broadcasts, + get_broadcast, + get_channel_members_for_broadcast, + update_broadcast, + update_broadcast_status, +) +from src.modules.broadcast.broadcast_tasks import send_broadcast_task + +router = APIRouter() + + +def require_admin(current_user: User = Depends(get_current_user)) -> User: + """Require admin role for access.""" + if current_user.role != Role.ADMIN: + raise HTTPException(status_code=403, detail="Admin access required") + return current_user + + +def build_broadcast_response(broadcast) -> dict: + """Build broadcast response data.""" + return { + "id": broadcast.id, + "subject": broadcast.subject, + "content": broadcast.content, + "recipient_type": broadcast.recipient_type.value, + "test_email": broadcast.test_email, + "channel_id": broadcast.channel_id, + "channel": broadcast.channel, + "status": broadcast.status.value, + "sent_at": broadcast.sent_at, + "sent_count": broadcast.sent_count, + "failed_count": broadcast.failed_count, + "created_by": broadcast.created_by, + "creator": broadcast.creator, + "created_at": broadcast.created_at, + "updated_at": broadcast.updated_at, + } + + +@router.post("/broadcasts/", response_model=BroadcastResponse) +def create_broadcast_endpoint( + broadcast: BroadcastCreate, + db: Session = Depends(get_session), + current_user: User = Depends(require_admin), +): + """Create a new broadcast.""" + broadcast_data = broadcast.model_dump() + broadcast_data["created_by"] = current_user.id + + if broadcast_data.get("recipient_type"): + broadcast_data["recipient_type"] = BroadcastRecipientType( + broadcast_data["recipient_type"] + ) + + created_broadcast = create_broadcast(db, broadcast_data) + full_broadcast = get_broadcast(db, created_broadcast.id) + return BroadcastResponse(**build_broadcast_response(full_broadcast)) + + +@router.get("/broadcasts/", response_model=List[BroadcastResponse]) +def get_all_broadcasts_endpoint( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_session), + current_user: User = Depends(require_admin), +): + """Get all broadcasts.""" + broadcasts = get_all_broadcasts(db, skip, limit) + return [BroadcastResponse(**build_broadcast_response(b)) for b in broadcasts] + + +@router.get("/broadcasts/{broadcast_id}", response_model=BroadcastResponse) +def get_broadcast_endpoint( + broadcast_id: str, + db: Session = Depends(get_session), + current_user: User = Depends(require_admin), +): + """Get a broadcast by ID.""" + broadcast = get_broadcast(db, broadcast_id) + if not broadcast: + raise HTTPException(status_code=404, detail="Broadcast not found") + return BroadcastResponse(**build_broadcast_response(broadcast)) + + +@router.put("/broadcasts/{broadcast_id}", response_model=BroadcastResponse) +def update_broadcast_endpoint( + broadcast_id: str, + broadcast: BroadcastUpdate, + db: Session = Depends(get_session), + current_user: User = Depends(require_admin), +): + """Update a broadcast.""" + existing = get_broadcast(db, broadcast_id) + if not existing: + raise HTTPException(status_code=404, detail="Broadcast not found") + + if existing.status != BroadcastStatus.DRAFT: + raise HTTPException( + status_code=400, detail="Can only update broadcasts in draft status" + ) + + update_data = {k: v for k, v in broadcast.model_dump().items() if v is not None} + if update_data.get("recipient_type"): + update_data["recipient_type"] = BroadcastRecipientType( + update_data["recipient_type"] + ) + + updated_broadcast = update_broadcast(db, broadcast_id, update_data) + return BroadcastResponse(**build_broadcast_response(updated_broadcast)) + + +@router.delete("/broadcasts/{broadcast_id}") +def delete_broadcast_endpoint( + broadcast_id: str, + db: Session = Depends(get_session), + current_user: User = Depends(require_admin), +): + """Delete a broadcast.""" + if not delete_broadcast(db, broadcast_id): + raise HTTPException(status_code=404, detail="Broadcast not found") + return {"message": "Broadcast deleted"} + + +@router.post("/broadcasts/{broadcast_id}/send-test") +def send_test_broadcast_endpoint( + broadcast_id: str, + test_data: BroadcastSendTest, + db: Session = Depends(get_session), + current_user: User = Depends(require_admin), +): + """Send a test broadcast to a specific email.""" + broadcast = get_broadcast(db, broadcast_id) + if not broadcast: + raise HTTPException(status_code=404, detail="Broadcast not found") + + create_broadcast_recipients( + db, broadcast_id, [{"email": test_data.test_email, "user_id": None}] + ) + + send_broadcast_task.delay(broadcast_id, is_test=True) + + return {"message": f"Test broadcast queued for {test_data.test_email}"} + + +@router.post("/broadcasts/{broadcast_id}/send") +def send_broadcast_endpoint( + broadcast_id: str, + db: Session = Depends(get_session), + current_user: User = Depends(require_admin), +): + """Send broadcast to users based on recipient type.""" + broadcast = get_broadcast(db, broadcast_id) + if not broadcast: + raise HTTPException(status_code=404, detail="Broadcast not found") + + if broadcast.status != BroadcastStatus.DRAFT: + raise HTTPException( + status_code=400, detail="Can only send broadcasts in draft status" + ) + + if broadcast.recipient_type == BroadcastRecipientType.CHANNEL_MEMBERS: + if not broadcast.channel_id: + raise HTTPException( + status_code=400, + detail="Channel ID required for channel members broadcast", + ) + users = get_channel_members_for_broadcast(db, broadcast.channel_id) + if not users: + raise HTTPException( + status_code=400, detail="No active members in this channel" + ) + else: + users = get_all_active_users(db) + if not users: + raise HTTPException(status_code=400, detail="No active users to send to") + + recipients_data = [{"email": user.email, "user_id": user.id} for user in users] + create_broadcast_recipients(db, broadcast_id, recipients_data) + + update_broadcast_status(db, broadcast_id, BroadcastStatus.SENDING) + + send_broadcast_task.delay(broadcast_id, is_test=False) + + return {"message": f"Broadcast queued for {len(users)} users"} diff --git a/apps/api/src/api/broadcast/serializer.py b/apps/api/src/api/broadcast/serializer.py new file mode 100644 index 0000000..5d0c52e --- /dev/null +++ b/apps/api/src/api/broadcast/serializer.py @@ -0,0 +1,48 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + +from src.api.channels.serializer import ChannelResponse +from src.api.user.serializer import UserResponse + + +class BroadcastCreate(BaseModel): + subject: str + content: str + recipient_type: str = "test_email" + test_email: Optional[str] = None + channel_id: Optional[str] = None + + +class BroadcastUpdate(BaseModel): + subject: Optional[str] = None + content: Optional[str] = None + recipient_type: Optional[str] = None + test_email: Optional[str] = None + channel_id: Optional[str] = None + + +class BroadcastSendTest(BaseModel): + test_email: str + + +class BroadcastResponse(BaseModel): + id: str + subject: str + content: str + recipient_type: str + test_email: Optional[str] = None + channel_id: Optional[str] = None + channel: Optional[ChannelResponse] = None + status: str + sent_at: Optional[str] = None + sent_count: int = 0 + failed_count: int = 0 + created_by: str + creator: Optional[UserResponse] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/apps/api/src/api/channels/api.py b/apps/api/src/api/channels/api.py index c41a995..32b5db6 100644 --- a/apps/api/src/api/channels/api.py +++ b/apps/api/src/api/channels/api.py @@ -7,7 +7,7 @@ from src.api.account.api import get_current_user from src.api.resources.serializer import ResourceResponse from src.database.engine import get_session as get_db -from src.database.models import User +from src.database.models import Role, User from src.modules.channels.channels_methods import ( create_channel, delete_channel, @@ -77,11 +77,14 @@ def get_channel_endpoint( # Check if user has access to this channel # Public channels are accessible to everyone - # Private channels require membership + # Private channels require membership (admins can access all) if channel.type == "private": from src.modules.channels.channels_methods import is_member - if not current_user or not is_member(db, channel_id, current_user.id): + is_admin = current_user and current_user.role == Role.ADMIN + if not is_admin and ( + not current_user or not is_member(db, channel_id, current_user.id) + ): raise HTTPException( status_code=403, detail="Access denied to private channel" ) @@ -115,7 +118,8 @@ def update_channel_endpoint( if existing_channel.type == "private": from src.modules.channels.channels_methods import is_member - if not is_member(db, channel_id, current_user.id): + is_admin = current_user.role == Role.ADMIN + if not is_admin and not is_member(db, channel_id, current_user.id): raise HTTPException( status_code=403, detail="Access denied to private channel" ) @@ -141,7 +145,8 @@ def delete_channel_endpoint( if existing_channel.type == "private": from src.modules.channels.channels_methods import is_member - if not is_member(db, channel_id, current_user.id): + is_admin = current_user.role == Role.ADMIN + if not is_admin and not is_member(db, channel_id, current_user.id): raise HTTPException( status_code=403, detail="Access denied to private channel" ) @@ -166,7 +171,10 @@ def get_resources_by_channel_endpoint( raise HTTPException(status_code=404, detail="Channel not found") if channel.type == "private": - if not current_user or not is_member(db, channel.id, current_user.id): + is_admin = current_user and current_user.role == Role.ADMIN + if not is_admin and ( + not current_user or not is_member(db, channel.id, current_user.id) + ): raise HTTPException( status_code=403, detail="Access denied to private channel" ) diff --git a/apps/api/src/core/celery_app.py b/apps/api/src/core/celery_app.py index 7f4702a..175eaf7 100644 --- a/apps/api/src/core/celery_app.py +++ b/apps/api/src/core/celery_app.py @@ -9,6 +9,7 @@ backend=settings.CELERY_RESULT_BACKEND, include=[ "src.modules.notifications.notification_tasks", + "src.modules.broadcast.broadcast_tasks", ], ) diff --git a/apps/api/src/database/models.py b/apps/api/src/database/models.py index bef2116..075c936 100644 --- a/apps/api/src/database/models.py +++ b/apps/api/src/database/models.py @@ -3,7 +3,8 @@ from enum import Enum from typing import List, Optional -from sqlalchemy import JSON +from sqlalchemy import JSON, Column +from sqlalchemy import Enum as SAEnum from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, relationship from sqlmodel import Field, Relationship @@ -526,3 +527,83 @@ class PendingNotificationEmail(BaseModel, table=True): notification: Mapped["Notification"] = Relationship( sa_relationship=relationship("Notification") ) + + +class BroadcastStatus(str, Enum): + DRAFT = "draft" + SENDING = "sending" + SENT = "sent" + FAILED = "failed" + + +class BroadcastRecipientType(str, Enum): + ALL_USERS = "all_users" + TEST_EMAIL = "test_email" + CHANNEL_MEMBERS = "channel_members" + + +class Broadcast(BaseModel, table=True): + __tablename__ = "broadcast" # type: ignore + + subject: str = Field(index=True) + content: str # Markdown/HTML content from WYSIWYG + recipient_type: BroadcastRecipientType = Field( + default=BroadcastRecipientType.TEST_EMAIL, + sa_column=Column( + SAEnum( + BroadcastRecipientType, values_callable=lambda x: [e.value for e in x] + ), + default=BroadcastRecipientType.TEST_EMAIL, + ), + ) + test_email: str | None = Field(default=None) + channel_id: str | None = Field(foreign_key="channel.id", default=None) + status: BroadcastStatus = Field( + default=BroadcastStatus.DRAFT, + sa_column=Column( + SAEnum(BroadcastStatus, values_callable=lambda x: [e.value for e in x]), + default=BroadcastStatus.DRAFT, + ), + ) + sent_at: str | None = Field(default=None) # ISO datetime string + sent_count: int = Field(default=0) + failed_count: int = Field(default=0) + created_by: str = Field(foreign_key="user.id") + creator: Mapped["User"] = Relationship( + sa_relationship=relationship("User", foreign_keys="Broadcast.created_by") + ) + channel: Mapped[Optional["Channel"]] = Relationship( + sa_relationship=relationship("Channel") + ) + recipients: Mapped[List["BroadcastRecipient"]] = Relationship( + sa_relationship=relationship("BroadcastRecipient", back_populates="broadcast") + ) + + +class BroadcastRecipientStatus(str, Enum): + PENDING = "pending" + SENT = "sent" + FAILED = "failed" + + +class BroadcastRecipient(BaseModel, table=True): + __tablename__ = "broadcast_recipient" # type: ignore + + broadcast_id: str = Field(foreign_key="broadcast.id", index=True) + user_id: str | None = Field(foreign_key="user.id", default=None, index=True) + email: str = Field(index=True) + status: BroadcastRecipientStatus = Field( + default=BroadcastRecipientStatus.PENDING, + sa_column=Column( + SAEnum( + BroadcastRecipientStatus, values_callable=lambda x: [e.value for e in x] + ), + default=BroadcastRecipientStatus.PENDING, + ), + ) + sent_at: str | None = Field(default=None) # ISO datetime string + error_message: str | None = Field(default=None) + broadcast: Mapped["Broadcast"] = Relationship( + sa_relationship=relationship("Broadcast", back_populates="recipients") + ) + user: Mapped[Optional["User"]] = Relationship(sa_relationship=relationship("User")) diff --git a/apps/api/src/main.py b/apps/api/src/main.py index fa03b61..3f1d50f 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -10,6 +10,7 @@ from src.api.appsettings.api import router as appsettings_router from src.api.article.api import router as article_router from src.api.auth.api import router as auth_router +from src.api.broadcast.api import router as broadcast_router from src.api.channel_members.api import router as channel_members_router from src.api.channels.api import router as channels_router from src.api.courses.api import router as courses_router @@ -80,6 +81,7 @@ async def lifespan(app: FastAPI): app.include_router(auth_router, prefix="/api", tags=["auth"]) app.include_router(reaction_router, prefix="/api", tags=["reactions"]) app.include_router(article_router, prefix="/api", tags=["articles"]) +app.include_router(broadcast_router, prefix="/api", tags=["broadcasts"]) app.include_router(extras_router, prefix="/api", tags=["extras"]) app.include_router(invite_code_router, prefix="/api", tags=["invite-codes"]) app.include_router(notifications_router, prefix="/api", tags=["notifications"]) diff --git a/apps/api/src/modules/broadcast/__init__.py b/apps/api/src/modules/broadcast/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/api/src/modules/broadcast/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/api/src/modules/broadcast/broadcast_methods.py b/apps/api/src/modules/broadcast/broadcast_methods.py new file mode 100644 index 0000000..eba4b81 --- /dev/null +++ b/apps/api/src/modules/broadcast/broadcast_methods.py @@ -0,0 +1,185 @@ +from datetime import datetime, timezone +from typing import List, Optional + +from sqlalchemy import column, desc +from sqlmodel import Session, select + +from src.database.models import ( + Broadcast, + BroadcastRecipient, + BroadcastRecipientStatus, + BroadcastRecipientType, + BroadcastStatus, + ChannelMember, + User, +) + + +def create_broadcast(db: Session, broadcast_data: dict) -> Broadcast: + """Create a new broadcast.""" + broadcast = Broadcast( + subject=broadcast_data["subject"], + content=broadcast_data["content"], + recipient_type=broadcast_data.get( + "recipient_type", BroadcastRecipientType.TEST_EMAIL + ), + test_email=broadcast_data.get("test_email"), + channel_id=broadcast_data.get("channel_id"), + status=BroadcastStatus.DRAFT, + created_by=broadcast_data["created_by"], + ) + db.add(broadcast) + db.commit() + db.refresh(broadcast) + return broadcast + + +def get_broadcast(db: Session, broadcast_id: str) -> Optional[Broadcast]: + """Get a broadcast by ID.""" + statement = select(Broadcast).where( + Broadcast.id == broadcast_id, Broadcast.deleted_at.is_(None) + ) + result = db.exec(statement).first() + return result + + +def get_all_broadcasts(db: Session, skip: int = 0, limit: int = 100) -> List[Broadcast]: + """Get all broadcasts with pagination.""" + statement = ( + select(Broadcast) + .where(Broadcast.deleted_at.is_(None)) + .order_by(desc(column("created_at"))) + .offset(skip) + .limit(limit) + ) + result = db.exec(statement).all() + return list(result) + + +def update_broadcast( + db: Session, broadcast_id: str, update_data: dict +) -> Optional[Broadcast]: + """Update a broadcast.""" + broadcast = get_broadcast(db, broadcast_id) + if not broadcast: + return None + + for key, value in update_data.items(): + if value is not None: + setattr(broadcast, key, value) + + db.commit() + db.refresh(broadcast) + return broadcast + + +def delete_broadcast(db: Session, broadcast_id: str) -> bool: + """Soft delete a broadcast.""" + broadcast = get_broadcast(db, broadcast_id) + if not broadcast: + return False + + broadcast.deleted_at = datetime.now(timezone.utc) + db.commit() + return True + + +def get_all_active_users(db: Session) -> List[User]: + """Get all active users for broadcast.""" + statement = select(User).where( + User.is_active == True, # noqa: E712 + User.deleted_at.is_(None), + ) + result = db.exec(statement).all() + return list(result) + + +def get_channel_members_for_broadcast(db: Session, channel_id: str) -> List[User]: + """Get all active users who are members of a channel.""" + statement = ( + select(User) + .join(ChannelMember, ChannelMember.user_id == User.id) + .where( + ChannelMember.channel_id == channel_id, + User.is_active == True, # noqa: E712 + User.deleted_at.is_(None), + ) + ) + result = db.exec(statement).all() + return list(result) + + +def create_broadcast_recipients( + db: Session, broadcast_id: str, emails: List[dict] +) -> List[BroadcastRecipient]: + """Create broadcast recipients.""" + recipients = [] + for email_data in emails: + recipient = BroadcastRecipient( + broadcast_id=broadcast_id, + user_id=email_data.get("user_id"), + email=email_data["email"], + status=BroadcastRecipientStatus.PENDING, + ) + db.add(recipient) + recipients.append(recipient) + db.commit() + for r in recipients: + db.refresh(r) + return recipients + + +def update_broadcast_recipient_status( + db: Session, + recipient_id: str, + status: BroadcastRecipientStatus, + error_message: Optional[str] = None, +) -> Optional[BroadcastRecipient]: + """Update broadcast recipient status.""" + statement = select(BroadcastRecipient).where(BroadcastRecipient.id == recipient_id) + recipient = db.exec(statement).first() + if not recipient: + return None + + recipient.status = status + if status == BroadcastRecipientStatus.SENT: + recipient.sent_at = datetime.now(timezone.utc).isoformat() + if error_message: + recipient.error_message = error_message + + db.commit() + db.refresh(recipient) + return recipient + + +def update_broadcast_status( + db: Session, + broadcast_id: str, + status: BroadcastStatus, + sent_count: int = 0, + failed_count: int = 0, +) -> Optional[Broadcast]: + """Update broadcast status and counts.""" + broadcast = get_broadcast(db, broadcast_id) + if not broadcast: + return None + + broadcast.status = status + broadcast.sent_count = sent_count + broadcast.failed_count = failed_count + if status == BroadcastStatus.SENT: + broadcast.sent_at = datetime.now(timezone.utc).isoformat() + + db.commit() + db.refresh(broadcast) + return broadcast + + +def get_pending_recipients(db: Session, broadcast_id: str) -> List[BroadcastRecipient]: + """Get all pending recipients for a broadcast.""" + statement = select(BroadcastRecipient).where( + BroadcastRecipient.broadcast_id == broadcast_id, + BroadcastRecipient.status == BroadcastRecipientStatus.PENDING, + ) + result = db.exec(statement).all() + return list(result) diff --git a/apps/api/src/modules/broadcast/broadcast_tasks.py b/apps/api/src/modules/broadcast/broadcast_tasks.py new file mode 100644 index 0000000..3f36fd4 --- /dev/null +++ b/apps/api/src/modules/broadcast/broadcast_tasks.py @@ -0,0 +1,122 @@ +import re + +import markdown +from loguru import logger +from sqlmodel import Session + +from src.core.celery_app import celery_app +from src.database.engine import engine +from src.database.models import BroadcastRecipientStatus, BroadcastStatus +from src.modules.broadcast.broadcast_methods import ( + get_broadcast, + get_pending_recipients, + update_broadcast_recipient_status, + update_broadcast_status, +) +from src.modules.email.email_service import email_service + +EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") + + +def markdown_to_html(content: str) -> str: + """Convert markdown content to HTML with email-friendly wrapper.""" + html_body = markdown.markdown( + content, + extensions=["extra", "nl2br", "sane_lists"], + ) + return f""" +
+ {html_body} +
+ """ + + +def is_valid_email(email: str) -> bool: + """Validate email format.""" + if not email or not isinstance(email, str): + return False + return bool(EMAIL_REGEX.match(email.strip())) + + +@celery_app.task +def send_broadcast_task(broadcast_id: str, is_test: bool = False): + """Send broadcast emails as a background task.""" + with Session(engine) as db: + try: + broadcast = get_broadcast(db, broadcast_id) + if not broadcast: + logger.error(f"Broadcast {broadcast_id} not found") + return {"success": False, "error": "Broadcast not found"} + + pending_recipients = get_pending_recipients(db, broadcast_id) + if not pending_recipients: + logger.info(f"No pending recipients for broadcast {broadcast_id}") + return {"success": True, "message": "No pending recipients"} + + sent_count = 0 + failed_count = 0 + + for recipient in pending_recipients: + try: + if not is_valid_email(recipient.email): + logger.warning(f"Skipping invalid email: {recipient.email}") + update_broadcast_recipient_status( + db, + recipient.id, + BroadcastRecipientStatus.FAILED, + "Invalid email format", + ) + failed_count += 1 + continue + + html_content = markdown_to_html(broadcast.content) + success = email_service.send_broadcast_email( + to_email=recipient.email.strip(), + subject=broadcast.subject, + html_content=html_content, + ) + + if success: + update_broadcast_recipient_status( + db, recipient.id, BroadcastRecipientStatus.SENT + ) + sent_count += 1 + else: + update_broadcast_recipient_status( + db, + recipient.id, + BroadcastRecipientStatus.FAILED, + "Failed to send email", + ) + failed_count += 1 + + except Exception as e: + logger.error(f"Failed to send to {recipient.email}: {str(e)}") + update_broadcast_recipient_status( + db, + recipient.id, + BroadcastRecipientStatus.FAILED, + str(e), + ) + failed_count += 1 + + if not is_test: + final_status = ( + BroadcastStatus.SENT if sent_count > 0 else BroadcastStatus.FAILED + ) + update_broadcast_status( + db, broadcast_id, final_status, sent_count, failed_count + ) + + return { + "success": True, + "sent_count": sent_count, + "failed_count": failed_count, + "message": f"Broadcast completed: {sent_count} sent, {failed_count} failed", + } + + except Exception as e: + logger.error(f"Failed to process broadcast {broadcast_id}: {str(e)}") + if not is_test: + update_broadcast_status(db, broadcast_id, BroadcastStatus.FAILED, 0, 0) + return {"success": False, "error": str(e)} diff --git a/apps/api/src/modules/email/email_service.py b/apps/api/src/modules/email/email_service.py index 3b553af..0a759f6 100644 --- a/apps/api/src/modules/email/email_service.py +++ b/apps/api/src/modules/email/email_service.py @@ -201,6 +201,15 @@ def send_notification_digest_email( return self._send_email(to_email, subject, html_content) + def send_broadcast_email( + self, + to_email: str, + subject: str, + html_content: str, + ) -> bool: + """Send broadcast email with custom subject and HTML content.""" + return self._send_email(to_email, subject, html_content) + # Create a singleton instance email_service = EmailService() diff --git a/apps/api/uv.lock b/apps/api/uv.lock index 6b9b92e..170dda9 100644 --- a/apps/api/uv.lock +++ b/apps/api/uv.lock @@ -66,6 +66,7 @@ dependencies = [ { name = "fastapi" }, { name = "httpx-oauth" }, { name = "loguru" }, + { name = "markdown" }, { name = "passlib" }, { name = "psycopg2-binary" }, { name = "pydantic-settings" }, @@ -105,6 +106,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.119.0" }, { name = "httpx-oauth", specifier = ">=0.15.0" }, { name = "loguru", specifier = ">=0.7.3" }, + { name = "markdown", specifier = ">=3.7" }, { name = "passlib", specifier = ">=1.7.4" }, { name = "psycopg2-binary", specifier = ">=2.9.11" }, { name = "pydantic-settings", specifier = ">=2.11.0" }, @@ -672,6 +674,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, ] +[[package]] +name = "markdown" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" diff --git a/apps/platform/package.json b/apps/platform/package.json index 7b1b0a3..ddddbed 100644 --- a/apps/platform/package.json +++ b/apps/platform/package.json @@ -1,7 +1,7 @@ { "name": "platform", "private": true, - "version": "0.0.14", + "version": "0.0.15", "type": "module", "scripts": { "dev": "vite", diff --git a/docs/www/package.json b/docs/www/package.json index ba3c1d4..42bf639 100644 --- a/docs/www/package.json +++ b/docs/www/package.json @@ -1,6 +1,6 @@ { "name": "www", - "version": "0.0.14", + "version": "0.0.15", "type": "module", "private": true, "scripts": { diff --git a/package.json b/package.json index fe5e951..71d0169 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencircle", - "version": "0.0.14", + "version": "0.0.15", "description": "", "main": "index.js", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 929f118..bd2e8d3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@opencircle/core", - "version": "0.0.14", + "version": "0.0.15", "description": "", "main": "dist/index.js", "types": "src/index.ts", diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 588946c..17de18d 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -6,6 +6,7 @@ export { } from "./routers/appsettings"; export { ArticlesRouter } from "./routers/articles"; export { AuthRouter } from "./routers/auth"; +export { BroadcastsRouter } from "./routers/broadcasts"; export { ChannelsRouter } from "./routers/channels"; export { CoursesRouter, diff --git a/packages/core/src/services/routers/broadcasts/index.ts b/packages/core/src/services/routers/broadcasts/index.ts new file mode 100644 index 0000000..b1a532c --- /dev/null +++ b/packages/core/src/services/routers/broadcasts/index.ts @@ -0,0 +1,52 @@ +import { BaseRouter } from "../../../utils/baseRouter"; +import type { + Broadcast, + BroadcastCreate, + BroadcastSendTest, + BroadcastUpdate, +} from "./types"; + +export class BroadcastsRouter extends BaseRouter { + async getAll(skip: number = 0, limit: number = 100): Promise { + const params = new URLSearchParams({ + skip: skip.toString(), + limit: limit.toString(), + }); + return this.client.get(`broadcasts/?${params.toString()}`); + } + + async getById(broadcastId: string): Promise { + return this.client.get(`broadcasts/${broadcastId}`); + } + + async create(data: BroadcastCreate): Promise { + return this.client.post("broadcasts/", data); + } + + async update(broadcastId: string, data: BroadcastUpdate): Promise { + return this.client.put(`broadcasts/${broadcastId}`, data); + } + + async delete(broadcastId: string): Promise<{ message: string }> { + return this.client.delete<{ message: string }>(`broadcasts/${broadcastId}`); + } + + async sendTest( + broadcastId: string, + data: BroadcastSendTest, + ): Promise<{ message: string }> { + return this.client.post<{ message: string }>( + `broadcasts/${broadcastId}/send-test`, + data, + ); + } + + async send(broadcastId: string): Promise<{ message: string }> { + return this.client.post<{ message: string }>( + `broadcasts/${broadcastId}/send`, + {}, + ); + } +} + +export * from "./types"; diff --git a/packages/core/src/services/routers/broadcasts/types.ts b/packages/core/src/services/routers/broadcasts/types.ts new file mode 100644 index 0000000..89088cc --- /dev/null +++ b/packages/core/src/services/routers/broadcasts/types.ts @@ -0,0 +1,46 @@ +import type { User } from "../auth/types"; +import type { Channel } from "../channels/types"; + +export type BroadcastStatus = "draft" | "sending" | "sent" | "failed"; +export type BroadcastRecipientType = + | "all_users" + | "test_email" + | "channel_members"; + +export interface Broadcast { + id: string; + subject: string; + content: string; + recipient_type: BroadcastRecipientType; + test_email?: string; + channel_id?: string; + channel?: Channel; + status: BroadcastStatus; + sent_at?: string; + sent_count: number; + failed_count: number; + created_by: string; + creator?: User; + created_at: string; + updated_at: string; +} + +export interface BroadcastCreate { + subject: string; + content: string; + recipient_type?: BroadcastRecipientType; + test_email?: string; + channel_id?: string; +} + +export interface BroadcastUpdate { + subject?: string; + content?: string; + recipient_type?: BroadcastRecipientType; + test_email?: string; + channel_id?: string; +} + +export interface BroadcastSendTest { + test_email: string; +} diff --git a/packages/core/src/services/types.ts b/packages/core/src/services/types.ts index 5723eed..7461b8b 100644 --- a/packages/core/src/services/types.ts +++ b/packages/core/src/services/types.ts @@ -4,6 +4,7 @@ export * from "./routers/applinks/types"; export * from "./routers/appsettings/types"; export * from "./routers/articles/types"; export * from "./routers/auth/types"; +export * from "./routers/broadcasts/types"; export * from "./routers/channels/types"; export * from "./routers/courses/types"; export * from "./routers/extras/types"; diff --git a/packages/core/src/utils/api.ts b/packages/core/src/utils/api.ts index c20c577..f2b34b2 100644 --- a/packages/core/src/utils/api.ts +++ b/packages/core/src/utils/api.ts @@ -4,6 +4,7 @@ import { AppLinksRouter } from "../services/routers/applinks"; import { AppSettingsRouter } from "../services/routers/appsettings"; import { ArticlesRouter } from "../services/routers/articles"; import { AuthRouter } from "../services/routers/auth"; +import { BroadcastsRouter } from "../services/routers/broadcasts"; import { ChannelsRouter } from "../services/routers/channels"; import { CoursesRouter } from "../services/routers/courses"; import { ExtrasRouter } from "../services/routers/extras"; @@ -21,6 +22,7 @@ export class Api { public auth: AuthRouter; public appSettings: AppSettingsRouter; public appLinks: AppLinksRouter; + public broadcasts: BroadcastsRouter; public channels: ChannelsRouter; public posts: PostsRouter; public media: MediaRouter; @@ -39,6 +41,7 @@ export class Api { this.auth = new AuthRouter(baseUrl, hooks); this.appSettings = new AppSettingsRouter(baseUrl, hooks); this.appLinks = new AppLinksRouter(baseUrl, hooks); + this.broadcasts = new BroadcastsRouter(baseUrl, hooks); this.channels = new ChannelsRouter(baseUrl, hooks); this.posts = new PostsRouter(baseUrl, hooks); this.media = new MediaRouter(baseUrl, hooks); diff --git a/packages/ui/package.json b/packages/ui/package.json index b70e2d6..4fb4da2 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencircle/ui", - "version": "0.0.14", + "version": "0.0.15", "description": "", "main": "src/index.ts", "scripts": {