diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ee432ab..de56c52f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [Unreleased] +### 新增 + +- Organization Phase 2:宿主内通知公告。Dashboard `/organization/announcements` 使用共享 `dashboard/src/org-ui` 页面;CRUD 走 `/api/org-module/announcements`(`{FREEOS_HOME}/org/announcements.sqlite`),不需要 Node sidecar。`freeos org export-standalone` 写出同一页面的独立站骨架(完整打包仍待 Phase 5)。 + ### 修复 diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 0c2fc469..28924c80 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -180,7 +180,12 @@ "assemblePreviewExperts": "Opening Experts so you can select the imported colleagues.", "packReceipt": "Packed and pushed to the signed-in openXYOS tenant.", "packLanded": "Signed-in tenant {{tenant}}: employees {{employees}}, talent {{talent}}, skills {{skills}}, plugins {{plugins}}", - "packPreviewEmployees": "The preview now opens Employees so you can confirm the result." + "packPreviewEmployees": "The preview now opens Employees so you can confirm the result.", + "navWorkbench": "Workbench", + "navAnnouncements": "Announcements", + "announcementsTitle": "Announcements", + "announcementsBody": "Publish notices and track who has read them. This page lives in the host — no sidecar or second port.", + "openAnnouncements": "Open announcements" }, "systemSettings": { "subtitle": "Workbench, remote desktop, and host administration in one place." diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 66fc3aa6..9206764c 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -180,7 +180,12 @@ "assemblePreviewExperts": "正在打开「专家」页,导入的同事可在那里选用。", "packReceipt": "已打包并推送到当前登录的 openXYOS 租户。", "packLanded": "当前登录租户 {{tenant}}:员工 {{employees}},人才 {{talent}},技能 {{skills}},插件 {{plugins}}", - "packPreviewEmployees": "右侧预览已打开员工页,可直接核对。" + "packPreviewEmployees": "右侧预览已打开员工页,可直接核对。", + "navWorkbench": "工作台", + "navAnnouncements": "通知公告", + "announcementsTitle": "通知公告", + "announcementsBody": "发布公告并跟踪已读。此页跑在宿主内,不需要边车或第二端口。", + "openAnnouncements": "打开通知公告" }, "systemSettings": { "subtitle": "工作台、远程桌面与主机管理集中在这里。" diff --git a/dashboard/src/org-ui/api/createClient.test.ts b/dashboard/src/org-ui/api/createClient.test.ts new file mode 100644 index 00000000..826dbd45 --- /dev/null +++ b/dashboard/src/org-ui/api/createClient.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; +import { createOrgApiClient } from "./createClient"; + +describe("createOrgApiClient", () => { + it("calls the in-host announcements prefix and unwraps the envelope", async () => { + const fetchJson = vi.fn(async (path: string) => { + if (path.startsWith("/org-module/announcements?")) { + return { + success: true, + data: { + list: [{ id: 1, title: "Hi" }], + total: 1, + page: 1, + limit: 10, + }, + }; + } + if (path === "/org-module/announcements/action/unread") { + return { success: true, data: { count: 2 } }; + } + throw new Error(`unexpected ${path}`); + }); + const client = createOrgApiClient({ fetchJson }); + const listed = await client.announcements.list({ + page: 1, + limit: 10, + type: "all", + }); + expect(listed.total).toBe(1); + expect(listed.list[0]?.title).toBe("Hi"); + expect(await client.announcements.unread()).toEqual({ count: 2 }); + expect(fetchJson).toHaveBeenCalled(); + }); + + it("throws the envelope error", async () => { + const client = createOrgApiClient({ + fetchJson: async () => ({ success: false, error: "Permission denied" }), + }); + await expect( + client.announcements.create({ + title: "x", + content: "y", + type: "notice", + priority: "normal", + is_pinned: false, + expires_at: null, + }), + ).rejects.toThrow("Permission denied"); + }); +}); diff --git a/dashboard/src/org-ui/api/createClient.ts b/dashboard/src/org-ui/api/createClient.ts new file mode 100644 index 00000000..f0246235 --- /dev/null +++ b/dashboard/src/org-ui/api/createClient.ts @@ -0,0 +1,167 @@ +import type { OrgFetcher } from "../shell"; + +export type AnnouncementType = "notice" | "policy" | "news" | "emergency"; +export type AnnouncementPriority = "low" | "normal" | "important" | "urgent"; + +export interface Announcement { + id: number; + title: string; + content: string; + type: AnnouncementType | string; + priority: AnnouncementPriority | string; + is_pinned: number; + is_read?: boolean; + read_count?: number; + total_users?: number; + read_percent?: number; + published_at: string; + expires_at: string | null; + creator_name?: string; + created_at?: string; + updated_at?: string | null; +} + +export interface AnnouncementListData { + list: Announcement[]; + total: number; + page: number; + limit: number; +} + +export interface AnnouncementWrite { + title: string; + content: string; + type: string; + priority: string; + is_pinned: boolean | number; + expires_at: string | null; +} + +export interface OrgEnvelope { + success: boolean; + data?: T; + error?: string; +} + +export interface OrgAnnouncementsClient { + list(params: { + page: number; + limit: number; + type: string; + search?: string; + }): Promise; + get(id: number): Promise; + unread(): Promise<{ count: number }>; + markRead(id: number): Promise; + markAllRead(): Promise<{ marked: number }>; + create(body: AnnouncementWrite): Promise; + update(id: number, body: AnnouncementWrite): Promise; + remove(id: number): Promise; + togglePin(id: number): Promise<{ is_pinned: boolean }>; + pinned(): Promise; +} + +export interface OrgApiClient { + announcements: OrgAnnouncementsClient; +} + +async function unwrap(payload: OrgEnvelope): Promise { + if (!payload.success) { + throw new Error(payload.error || "request failed"); + } + return payload.data as T; +} + +/** + * Factory used by Dashboard (FreeOS JWT via `request`) and standalone export. + * Default prefix matches the in-host BFF; standalone can pass `/announcements`. + */ +export function createOrgApiClient(opts: { + fetchJson: OrgFetcher; + announcementsPrefix?: string; +}): OrgApiClient { + const prefix = opts.announcementsPrefix ?? "/org-module/announcements"; + const fetchJson = opts.fetchJson; + + const announcements: OrgAnnouncementsClient = { + async list(params) { + const query = new URLSearchParams({ + page: String(params.page), + limit: String(params.limit), + type: params.type, + }); + if (params.search) query.set("search", params.search); + return unwrap( + await fetchJson>( + `${prefix}?${query.toString()}`, + ), + ); + }, + async get(id) { + return unwrap( + await fetchJson>(`${prefix}/${id}`), + ); + }, + async unread() { + return unwrap( + await fetchJson>( + `${prefix}/action/unread`, + ), + ); + }, + async markRead(id) { + await unwrap( + await fetchJson>(`${prefix}/${id}/read`, { + method: "POST", + }), + ); + }, + async markAllRead() { + return unwrap( + await fetchJson>(`${prefix}/read-all`, { + method: "POST", + }), + ); + }, + async create(body) { + return unwrap( + await fetchJson>(prefix, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); + }, + async update(id, body) { + return unwrap( + await fetchJson>(`${prefix}/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); + }, + async remove(id) { + await unwrap( + await fetchJson>(`${prefix}/${id}`, { + method: "DELETE", + }), + ); + }, + async togglePin(id) { + return unwrap( + await fetchJson>( + `${prefix}/${id}/toggle-pin`, + { method: "PUT" }, + ), + ); + }, + async pinned() { + return unwrap( + await fetchJson>(`${prefix}/pinned`), + ); + }, + }; + + return { announcements }; +} diff --git a/dashboard/src/org-ui/contract.ts b/dashboard/src/org-ui/contract.ts new file mode 100644 index 00000000..457a340e --- /dev/null +++ b/dashboard/src/org-ui/contract.ts @@ -0,0 +1,4 @@ +/** Catalog keys that have a shared org-ui page (keep in sync with contract.py). */ +export const SHARED_ORG_UI_MODULES = ["announcements"] as const; + +export type SharedOrgUiModule = (typeof SHARED_ORG_UI_MODULES)[number]; diff --git a/dashboard/src/org-ui/index.ts b/dashboard/src/org-ui/index.ts new file mode 100644 index 00000000..c0718a04 --- /dev/null +++ b/dashboard/src/org-ui/index.ts @@ -0,0 +1,20 @@ +export { SHARED_ORG_UI_MODULES } from "./contract"; +export type { SharedOrgUiModule } from "./contract"; +export { createOrgApiClient } from "./api/createClient"; +export type { + Announcement, + AnnouncementListData, + AnnouncementWrite, + OrgAnnouncementsClient, + OrgApiClient, + OrgEnvelope, +} from "./api/createClient"; +export { AnnouncementPage } from "./pages/announcements/AnnouncementPage"; +export type { AnnouncementPageProps } from "./pages/announcements/AnnouncementPage"; +export type { + IdentityBridge, + OrgFetcher, + OrgLocale, + OrgSession, + ShellAdapter, +} from "./shell"; diff --git a/dashboard/src/org-ui/pages/announcements/AnnouncementPage.module.css b/dashboard/src/org-ui/pages/announcements/AnnouncementPage.module.css new file mode 100644 index 00000000..2ab9eb3c --- /dev/null +++ b/dashboard/src/org-ui/pages/announcements/AnnouncementPage.module.css @@ -0,0 +1,81 @@ +.page { + max-width: 880px; + margin: 0 auto; + padding: 8px 4px 32px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.titleRow { + display: flex; + align-items: center; + gap: 10px; +} + +.title { + margin: 0; + font-size: 20px; + font-weight: 650; +} + +.filters { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.card { + cursor: pointer; +} + +.cardTitle { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.unreadDot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #ff4d4f; + flex-shrink: 0; +} + +.preview { + margin: 6px 0 0; + color: var(--fn-text-secondary, #667085); + font-size: 13px; +} + +.meta { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 12px; + font-size: 12px; + color: var(--fn-text-secondary, #667085); +} + +.empty { + text-align: center; + padding: 48px 12px; + color: var(--fn-text-secondary, #667085); +} + +.detailBody { + white-space: pre-wrap; + line-height: 1.6; +} diff --git a/dashboard/src/org-ui/pages/announcements/AnnouncementPage.test.tsx b/dashboard/src/org-ui/pages/announcements/AnnouncementPage.test.tsx new file mode 100644 index 00000000..3ba49ae8 --- /dev/null +++ b/dashboard/src/org-ui/pages/announcements/AnnouncementPage.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { AnnouncementPage } from "./AnnouncementPage"; +import type { OrgAnnouncementsClient } from "../../api/createClient"; + +function mockClient(): OrgAnnouncementsClient { + return { + list: vi.fn(async () => ({ list: [], total: 0, page: 1, limit: 10 })), + get: vi.fn(), + unread: vi.fn(async () => ({ count: 0 })), + markRead: vi.fn(), + markAllRead: vi.fn(async () => ({ marked: 0 })), + create: vi.fn(async (body) => ({ + id: 9, + title: body.title, + content: body.content, + type: body.type, + priority: body.priority, + is_pinned: body.is_pinned ? 1 : 0, + published_at: "2026-09-19T00:00:00Z", + expires_at: null, + })), + update: vi.fn(), + remove: vi.fn(), + togglePin: vi.fn(), + pinned: vi.fn(async () => []), + }; +} + +describe("AnnouncementPage", () => { + it("renders the shared page without an iframe", async () => { + const client = mockClient(); + render( + , + ); + expect( + await screen.findByTestId("org-ui-announcements"), + ).toBeInTheDocument(); + expect(screen.getByText("Announcements")).toBeInTheDocument(); + expect(screen.getByTestId("org-announcements-empty")).toBeInTheDocument(); + expect(document.querySelector("iframe")).toBeNull(); + expect(client.list).toHaveBeenCalled(); + }); + + it("lets an admin publish through the injected client", async () => { + const client = mockClient(); + render( + , + ); + fireEvent.click(await screen.findByTestId("org-announcements-publish")); + fireEvent.change(screen.getByTestId("org-announcement-title"), { + target: { value: "Hello" }, + }); + fireEvent.change(screen.getByTestId("org-announcement-content"), { + target: { value: "World" }, + }); + fireEvent.click(screen.getByTestId("org-announcement-submit")); + await waitFor(() => { + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ title: "Hello", content: "World" }), + ); + }); + }); +}); diff --git a/dashboard/src/org-ui/pages/announcements/AnnouncementPage.tsx b/dashboard/src/org-ui/pages/announcements/AnnouncementPage.tsx new file mode 100644 index 00000000..fcc3ae69 --- /dev/null +++ b/dashboard/src/org-ui/pages/announcements/AnnouncementPage.tsx @@ -0,0 +1,481 @@ +import { useCallback, useEffect, useState } from "react"; +import { + Button, + Checkbox, + Input, + Modal, + Pagination, + Select, + Space, + Tag, + Typography, +} from "antd"; +import { Megaphone, Pin, Plus, Search } from "lucide-react"; +import type { + Announcement, + OrgAnnouncementsClient, +} from "../../api/createClient"; +import type { OrgLocale, OrgSession } from "../../shell"; +import { announcementLabels, typeLabel } from "./labels"; +import styles from "./AnnouncementPage.module.css"; + +const TYPE_COLOR: Record = { + notice: "blue", + policy: "purple", + news: "green", + emergency: "red", +}; + +export interface AnnouncementPageProps { + client: OrgAnnouncementsClient; + session: OrgSession; + locale: OrgLocale; + timeZone?: string; + formatDateTime?: (iso: string) => string; + /** Present so export App.tsx can pass SHARED_ORG_UI_MODULES without unused props. */ + modules?: readonly string[]; +} + +function stripTags(html: string): string { + return html + .replace(/<[^>]*>/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +function defaultFormat(iso: string): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toISOString().replace("T", " ").slice(0, 16); +} + +export function AnnouncementPage({ + client, + session, + locale, + formatDateTime, +}: AnnouncementPageProps) { + const labels = announcementLabels(locale); + const format = formatDateTime ?? defaultFormat; + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [filterType, setFilterType] = useState("all"); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [unreadCount, setUnreadCount] = useState(0); + const [detail, setDetail] = useState(null); + const [showForm, setShowForm] = useState(false); + const [editId, setEditId] = useState(null); + const [form, setForm] = useState({ + title: "", + content: "", + type: "notice", + priority: "normal", + is_pinned: false, + expires_at: "", + }); + const limit = 10; + + const fetchList = useCallback(async () => { + setLoading(true); + try { + const data = await client.list({ + page, + limit, + type: filterType, + search, + }); + setItems(data.list); + setTotal(data.total); + } catch { + setItems([]); + setTotal(0); + } finally { + setLoading(false); + } + }, [client, page, filterType, search]); + + const fetchUnread = useCallback(async () => { + try { + const data = await client.unread(); + setUnreadCount(data.count); + } catch { + /* ignore */ + } + }, [client]); + + useEffect(() => { + void fetchList(); + void fetchUnread(); + }, [fetchList, fetchUnread]); + + const openDetail = async (row: Announcement) => { + try { + const next = await client.get(row.id); + setDetail(next); + setItems((prev) => + prev.map((item) => + item.id === row.id ? { ...item, is_read: true } : item, + ), + ); + void fetchUnread(); + } catch { + setDetail(row); + } + }; + + const markAllRead = async () => { + await client.markAllRead(); + setItems((prev) => prev.map((item) => ({ ...item, is_read: true }))); + setUnreadCount(0); + }; + + const openCreate = () => { + setEditId(null); + setForm({ + title: "", + content: "", + type: "notice", + priority: "normal", + is_pinned: false, + expires_at: "", + }); + setShowForm(true); + }; + + const openEdit = (row: Announcement) => { + setEditId(row.id); + setForm({ + title: row.title, + content: row.content, + type: row.type, + priority: row.priority, + is_pinned: row.is_pinned === 1, + expires_at: row.expires_at || "", + }); + setShowForm(true); + }; + + const submitForm = async () => { + if (!form.title.trim() || !form.content.trim()) { + window.alert(labels.required); + return; + } + const body = { + title: form.title.trim(), + content: form.content.trim(), + type: form.type, + priority: form.priority, + is_pinned: form.is_pinned, + expires_at: form.expires_at || null, + }; + if (editId) { + await client.update(editId, body); + } else { + await client.create(body); + } + setShowForm(false); + await fetchList(); + }; + + const deleteAnnouncement = async (id: number) => { + if (!window.confirm(labels.confirmDelete)) return; + await client.remove(id); + await fetchList(); + void fetchUnread(); + }; + + const togglePin = async (id: number) => { + await client.togglePin(id); + await fetchList(); + }; + + return ( +
+
+
+ +

{labels.title}

+ {unreadCount > 0 ? ( + + {unreadCount} {labels.unread} + + ) : null} +
+ + + {session.isAdmin ? ( + + ) : null} + +
+ +
+ + {[ + { key: "all", label: labels.all }, + { key: "notice", label: labels.notice }, + { key: "policy", label: labels.policy }, + { key: "news", label: labels.news }, + { key: "emergency", label: labels.emergency }, + ].map((filter) => ( + + ))} + + } + value={search} + onChange={(event) => { + setSearch(event.target.value); + setPage(1); + }} + placeholder={labels.search} + style={{ maxWidth: 240 }} + /> +
+ + {loading ? ( +
{labels.loading}
+ ) : items.length === 0 ? ( +
+ {labels.empty} +
+ ) : ( + + {items.map((row) => { + const expired = + Boolean(row.expires_at) && + new Date(row.expires_at as string).getTime() < Date.now(); + return ( +
void openDetail(row)} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === "Enter") void openDetail(row); + }} + style={{ + border: "1px solid var(--fn-border, #e5e7eb)", + borderRadius: 10, + padding: 14, + background: "var(--fn-bg-container, #fff)", + opacity: expired ? 0.65 : 1, + }} + > +
+ {row.is_pinned === 1 ? : null} + + {typeLabel(row.type, locale)} + + {!row.is_read ? : null} + + {row.title} + + {row.priority === "urgent" ? ( + {labels.urgent} + ) : null} + {expired ? {labels.expired} : null} + {session.isAdmin ? ( + event.stopPropagation()} + style={{ marginLeft: "auto" }} + > + + + + + ) : null} +
+

+ {stripTags(row.content).slice(0, 80)} +

+
+ {row.creator_name || labels.system} + {format(row.published_at)} + + {row.read_percent ?? 0}% ({row.read_count ?? 0}/ + {row.total_users ?? 1}) + +
+
+ ); + })} +
+ )} + + {total > limit ? ( + + ) : null} + + setDetail(null)} + width={720} + > + {detail ? ( +
+ + + {typeLabel(detail.type, locale)} + + + {labels.publisher}: {detail.creator_name || labels.system} + + + {labels.published}: {format(detail.published_at)} + + {detail.expires_at ? ( + + {labels.expires}: {format(detail.expires_at)} + + ) : null} + +
{detail.content}
+
+ ) : null} +
+ + setShowForm(false)} + onOk={() => void submitForm()} + okText={editId ? labels.save : labels.publish} + okButtonProps={{ "data-testid": "org-announcement-submit" }} + > + +
+ + {labels.fieldTitle} + + + setForm({ ...form, title: event.target.value }) + } + data-testid="org-announcement-title" + /> +
+
+ + {labels.fieldContent} + + + setForm({ ...form, content: event.target.value }) + } + data-testid="org-announcement-content" + /> +
+ + setForm({ ...form, priority: value })} + options={[ + { value: "low", label: labels.priorityLow }, + { value: "normal", label: labels.priorityNormal }, + { value: "important", label: labels.priorityImportant }, + { value: "urgent", label: labels.urgent }, + ]} + /> + + setForm({ ...form, is_pinned: event.target.checked }) + } + > + {labels.pin} + + +
+ + {labels.expiresAt} + + + setForm({ + ...form, + expires_at: event.target.value + ? `${event.target.value}:00` + : "", + }) + } + /> +
+
+
+
+ ); +} + +export default AnnouncementPage; diff --git a/dashboard/src/org-ui/pages/announcements/labels.ts b/dashboard/src/org-ui/pages/announcements/labels.ts new file mode 100644 index 00000000..52a9029d --- /dev/null +++ b/dashboard/src/org-ui/pages/announcements/labels.ts @@ -0,0 +1,91 @@ +import type { OrgLocale } from "../../shell"; + +const EN = { + title: "Announcements", + unread: "unread", + markAllRead: "Mark all read", + publish: "Publish announcement", + edit: "Edit announcement", + all: "All", + notice: "Notice", + policy: "Policy", + news: "News", + emergency: "Emergency", + search: "Search announcements...", + loading: "Loading...", + empty: "No announcements", + urgent: "Urgent", + expired: "Expired", + system: "System", + publisher: "Publisher", + published: "Published", + expires: "Expires", + read: "read", + fieldTitle: "Title", + fieldContent: "Content", + fieldType: "Type", + fieldPriority: "Priority", + pin: "Pin", + unpin: "Unpin", + expiresAt: "Expires", + save: "Save changes", + priorityLow: "Low", + priorityNormal: "Normal", + priorityImportant: "Important", + required: "Title and content are required", + confirmDelete: "Delete this announcement?", + editShort: "Edit", + deleteShort: "Delete", +}; + +const ZH: typeof EN = { + title: "通知公告", + unread: "条未读", + markAllRead: "全部已读", + publish: "发布公告", + edit: "编辑公告", + all: "全部", + notice: "通知", + policy: "制度", + news: "新闻", + emergency: "紧急", + search: "搜索公告...", + loading: "加载中...", + empty: "暂无公告", + urgent: "紧急", + expired: "已过期", + system: "系统", + publisher: "发布者", + published: "发布时间", + expires: "有效期至", + read: "已读", + fieldTitle: "标题", + fieldContent: "内容", + fieldType: "类型", + fieldPriority: "优先级", + pin: "置顶", + unpin: "取消置顶", + expiresAt: "有效期至", + save: "保存修改", + priorityLow: "低", + priorityNormal: "普通", + priorityImportant: "重要", + required: "标题和内容不能为空", + confirmDelete: "确定删除该公告?", + editShort: "编辑", + deleteShort: "删除", +}; + +export type AnnouncementLabels = typeof EN; + +export function announcementLabels(locale: OrgLocale): AnnouncementLabels { + return locale === "zh" ? ZH : EN; +} + +export function typeLabel(type: string, locale: OrgLocale): string { + const labels = announcementLabels(locale); + if (type === "policy") return labels.policy; + if (type === "news") return labels.news; + if (type === "emergency") return labels.emergency; + return labels.notice; +} diff --git a/dashboard/src/org-ui/shell.ts b/dashboard/src/org-ui/shell.ts new file mode 100644 index 00000000..2f6b3f45 --- /dev/null +++ b/dashboard/src/org-ui/shell.ts @@ -0,0 +1,26 @@ +/** Identity + shell contracts for embedded Dashboard and standalone export. */ + +export type OrgLocale = "zh" | "en"; + +export interface OrgSession { + userId: number; + displayName: string; + role: string; + isAdmin: boolean; +} + +export interface IdentityBridge { + getSession(): OrgSession; + authHeaders(): Record; + apiBase(): string; +} + +export interface ShellAdapter extends IdentityBridge { + locale: OrgLocale; + timeZone: string; + navigate(path: string): void; + /** Host-only actions (loop / assemble) stay off in standalone export. */ + showHostActions: boolean; +} + +export type OrgFetcher = (path: string, init?: RequestInit) => Promise; diff --git a/dashboard/src/pages/Organization/announcements/index.tsx b/dashboard/src/pages/Organization/announcements/index.tsx new file mode 100644 index 00000000..89bda143 --- /dev/null +++ b/dashboard/src/pages/Organization/announcements/index.tsx @@ -0,0 +1,51 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { request } from "../../../api/request"; +import { useCurrentUser } from "../../../hooks/useCurrentUser"; +import { useServerTimezone } from "../../../hooks/useServerTimezone"; +import PageShell from "../../../layouts/PageShell"; +import { + AnnouncementPage, + createOrgApiClient, + type OrgSession, +} from "../../../org-ui"; +import { formatServerIsoDateTime } from "../../../utils/formatMessageTime"; +import { useOrgPathTabs } from "../orgPathTabs"; + +export default function OrganizationAnnouncementsPage() { + const { t, i18n } = useTranslation(); + const user = useCurrentUser(); + const timeZone = useServerTimezone(); + const pathTabs = useOrgPathTabs("announcements"); + const client = useMemo( + () => + createOrgApiClient({ + fetchJson: (path, init) => request(path, init), + }), + [], + ); + const locale = i18n.language?.toLowerCase().startsWith("zh") ? "zh" : "en"; + const session: OrgSession = { + userId: user?.id ?? 0, + displayName: user?.display_name || user?.username || "", + role: user?.role || "user", + isAdmin: user?.role === "admin", + }; + + return ( + + formatServerIsoDateTime(iso, timeZone)} + /> + + ); +} diff --git a/dashboard/src/pages/Organization/index.test.tsx b/dashboard/src/pages/Organization/index.test.tsx index d8ce6bdc..a4d983ad 100644 --- a/dashboard/src/pages/Organization/index.test.tsx +++ b/dashboard/src/pages/Organization/index.test.tsx @@ -10,6 +10,12 @@ function renderOrg() { } /> + announcements + } + /> experts} @@ -140,6 +146,7 @@ describe("OrganizationPage", () => { expect(screen.getByText("organization.loopAction")).toBeInTheDocument(); expect(screen.getByTestId("org-colleagues")).toHaveTextContent("Ops"); expect(screen.getByTestId("org-surfaces")).toBeInTheDocument(); + expect(screen.getByTestId("org-open-announcements")).toBeInTheDocument(); expect(screen.queryByTestId("org-mini-browser")).toBeNull(); expect(screen.queryByTestId("org-browser-frame")).toBeNull(); expect(screen.queryByTestId("org-sidecar-gate")).toBeNull(); @@ -186,6 +193,15 @@ describe("OrganizationPage", () => { ); }); + it("opens in-host announcements without a sidecar iframe", async () => { + renderOrg(); + fireEvent.click(await screen.findByTestId("org-open-announcements")); + expect( + await screen.findByTestId("org-announcements-page"), + ).toBeInTheDocument(); + expect(screen.queryByTestId("org-browser-frame")).toBeNull(); + }); + it("imports into FreeOS and opens Experts so colleagues are selectable", async () => { vi.mocked(orgModuleApi.assemble).mockResolvedValue({ sidecar_reachable: false, diff --git a/dashboard/src/pages/Organization/index.tsx b/dashboard/src/pages/Organization/index.tsx index e723abf8..1a1de82e 100644 --- a/dashboard/src/pages/Organization/index.tsx +++ b/dashboard/src/pages/Organization/index.tsx @@ -30,6 +30,7 @@ import { } from "../../utils/desktopFolder"; import { message } from "../../utils/antdMessage"; import { resolveOpenxyosSourceDest } from "./pickSourceDest"; +import { useOrgPathTabs } from "./orgPathTabs"; import styles from "./Organization.module.less"; type ActionKey = "assemble" | "pack" | "loop" | "sidecar" | "produce" | null; @@ -352,9 +353,10 @@ export default function OrganizationPage() { const catalog = overview?.catalog ?? []; const colleagues = overview?.colleagues ?? []; const orgSurfaces = overview?.org_surfaces; + const pathTabs = useOrgPathTabs("workbench"); return ( - +
@@ -399,6 +401,25 @@ export default function OrganizationPage() {

{t("organization.heroStory")}

{t("organization.glossary")}

+
+
+ +

+ {t("organization.announcementsTitle")} +

+

+ {t("organization.announcementsBody")} +

+ +
+
+ {firstRun && (

diff --git a/dashboard/src/pages/Organization/orgPathTabs.tsx b/dashboard/src/pages/Organization/orgPathTabs.tsx new file mode 100644 index 00000000..c3590ff0 --- /dev/null +++ b/dashboard/src/pages/Organization/orgPathTabs.tsx @@ -0,0 +1,33 @@ +import { Building2, Megaphone } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import type { PathTabsConfig } from "../../layouts/PageShell"; + +export function useOrgPathTabs( + active: "workbench" | "announcements", +): PathTabsConfig { + const { t } = useTranslation(); + const navigate = useNavigate(); + return { + value: active, + options: [ + { + value: "workbench", + label: t("organization.navWorkbench"), + icon: , + }, + { + value: "announcements", + label: t("organization.navAnnouncements"), + icon: , + }, + ], + onChange: (value) => { + navigate( + value === "announcements" + ? "/organization/announcements" + : "/organization", + ); + }, + }; +} diff --git a/dashboard/src/routes/controlAdminPath.test.ts b/dashboard/src/routes/controlAdminPath.test.ts index b8b36772..4eb41fb0 100644 --- a/dashboard/src/routes/controlAdminPath.test.ts +++ b/dashboard/src/routes/controlAdminPath.test.ts @@ -67,6 +67,7 @@ describe("pathPermissionKeys", () => { expect(pathPermissionKeys("/chat")).toBeNull(); expect(pathPermissionKeys("/experts")).toBeNull(); expect(pathPermissionKeys("/organization")).toBeNull(); + expect(pathPermissionKeys("/organization/announcements")).toBeNull(); expect(pathPermissionKeys("/projects")).toBeNull(); expect(pathPermissionKeys("/tasks")).toBeNull(); expect(pathPermissionKeys("/token-usage")).toBeNull(); @@ -144,6 +145,9 @@ describe("unknown dashboard paths", () => { expect(resolveSelectedKey("/personalization/connectors")).toBe( "personalization", ); + expect(resolveSelectedKey("/organization/announcements")).toBe( + "organization", + ); }); it("are caught by the not-found route", () => { diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx index 228dd35c..b02a4947 100644 --- a/dashboard/src/routes/index.tsx +++ b/dashboard/src/routes/index.tsx @@ -18,6 +18,9 @@ const ModelsPage = lazy(() => import("../pages/Settings/Models")); const RemoteDesktopPage = lazy(() => import("../pages/Control/RemoteDesktop")); const OrganizationPage = lazy(() => import("../pages/Organization")); +const OrganizationAnnouncementsPage = lazy( + () => import("../pages/Organization/announcements"), +); const ProjectsPage = lazy(() => import("../pages/Projects")); // Misc @@ -71,6 +74,7 @@ export const pathToKey: Record = { // Common "/experts": "experts", "/organization": "organization", + "/organization/announcements": "organization", "/projects": "projects", "/tasks": "projects", "/connectors": "personalization", @@ -181,6 +185,7 @@ export function resolveSelectedKey(pathname: string, search = ""): string { const workspaceKey = resolveWorkspaceNavKey(pathname, search); if (workspaceKey) return workspaceKey; if (pathToKey[pathname]) return pathToKey[pathname]; + if (pathname.startsWith("/organization/")) return "organization"; if (pathname.startsWith("/personalization/")) return "personalization"; if (pathname === "/models" || pathname.startsWith("/models/")) return "models"; @@ -200,6 +205,10 @@ export const routeConfigs: RouteConfig[] = [ // Common { path: "/experts", element: }, { path: "/organization", element: }, + { + path: "/organization/announcements", + element: , + }, { path: "/projects", element: }, { path: "/tasks", element: }, { diff --git a/docs/architecture-integration.md b/docs/architecture-integration.md index a156963f..1f891564 100644 --- a/docs/architecture-integration.md +++ b/docs/architecture-integration.md @@ -289,8 +289,8 @@ The Phase A work sits on the existing FreeOS bootstrap: | Asset factory | Bridge | `freeos org assets publish\|import\|apply` · `{home}/asset-packs/` · `{home}/openxyos-mirror/` | | Self-growth loop | Bridge | `freeos org loop run` · `/api/org-module/loop/run` | | Colleague agents | Host agents table | `org-` · `{home}/org-agents/` · tenant `routing.json` | -| Org APIs | Host BFF + optional sidecar | `/api/org-module/*` in-host; sidecar `/api/org`, `/api/employees`, … until each CRUD slice moves (see [org-merge-plan.md](org-merge-plan.md)) | -| Org UI | Host Dashboard (target: `org-ui`) | Native `/organization` workbench (#55). Open-12 pages become Dashboard sub-UIs; standalone site is **exported** from the same source ([ADR 003](adr/003-org-ui-single-source-dual-delivery.md)). Sidecar `:3780` iframe is opt-in compat only | +| Org APIs | Host BFF + optional sidecar | `/api/org-module/*` in-host (announcements CRUD is in-host); sidecar `/api/org`, `/api/employees`, … until each remaining CRUD slice moves (see [org-merge-plan.md](org-merge-plan.md)) | +| Org UI | Host Dashboard (`org-ui`) | Native `/organization` workbench + `/organization/announcements` (shared `dashboard/src/org-ui`). Standalone site is **exported** from the same source (`freeos org export-standalone`). Sidecar `:3780` iframe is opt-in compat only | ### Auth and tenant mapping diff --git a/docs/org-merge-plan.md b/docs/org-merge-plan.md index 9a70d70a..e9d9d6a7 100644 --- a/docs/org-merge-plan.md +++ b/docs/org-merge-plan.md @@ -1,10 +1,10 @@ # 组织模块合并计划(openXYOS → FreeOS Dashboard) -**状态:** Phase 1 合同已冻结([ADR 003](adr/003-org-ui-single-source-dual-delivery.md)) +**状态:** Phase 2 Announcements 垂直切片已落地(宿主 CRUD + `/organization/announcements` + `org-ui` + `freeos org export-standalone` 骨架) **日期:** 2026-09-19 **依据:** Phase 0 迁移图、[architecture-integration.md](architecture-integration.md)、[asset-loop.md](asset-loop.md)、[ADR 001](adr/001-single-process-model.md)、#55 宿主内 Organization -本文是**规划**,不实现页面搬迁或安装器改动。 +Phase 1 冻结了合同。Phase 2(通知公告)已按该合同落地;其余 Open-12 页仍按阶段推进。 --- @@ -24,7 +24,7 @@ |---|---|---| | **0** 宿主内地基 | `org_os` 控制面、`/api/org-module/*` BFF、Dashboard 原生工作台、sidecar 改为可选 | **已完成**(#55) | | **1** 合同冻结 | ADR + 本计划:单源双交付、包布局、API 归属、IdentityBridge、Phase 2 验收 | **本文** | -| **2** 垂直切片 | **通知公告 Announcements** 按合同落地(Dashboard 路由 + 可导出同一页面) | 未开始 | +| **2** 垂直切片 | **通知公告 Announcements** 按合同落地(Dashboard 路由 + 可导出同一页面) | **本切片已完成** | | **3** Open-12 其余页 | 工作台、组织架构、员工、技能、智能体、任务、知识、反思、治理 UI、设置 | 未开始 | | **4** 身份与 CRUD | 已迁页面走 IdentityBridge;业务 CRUD 按切片迁入宿主;未迁路由仍可代理到可选 sidecar | 与 2–3 交叉推进 | | **5** 导出与安装器 | `freeos org export-standalone` 产出独立站;默认安装器保持零 Node | 未开始 | @@ -169,12 +169,12 @@ org-ui → IdentityBridge.getSession() 最低风险:只读+CRUD 清晰,不碰 Agent 运行时,OpenApp 与商业 `App.tsx` 都引用同一 `AnnouncementPage`。 -### 要做(实现波,非本 PR) +### 已落地(Phase 2 实现) -1. 把公告页抽成壳无关组件(`org-ui/pages/announcements`),数据经 `createOrgApiClient`。 -2. Dashboard 增加子路由:`/organization/announcements`(及详情/编辑若需要)。 -3. 宿主提供公告 API(见上表);嵌入模式打宿主,不打 `:3780`。 -4. 导出流水线最小闭环:同一页面能出现在 `export-standalone` 产物里(可用 fixture / 快照验收,不必先做完整商业打包)。 +1. 壳无关组件:`dashboard/src/org-ui/pages/announcements`,数据经 `createOrgApiClient`。 +2. Dashboard 子路由:`/organization/announcements`(工作台 path tabs + 入口卡)。 +3. 宿主 API:`/api/org-module/announcements*`,SQLite 在 `{FREEOS_HOME}/org/announcements.sqlite`;嵌入模式打宿主 JWT,不打 `:3780`。 +4. 导出骨架:`freeos org export-standalone --out …` 写出共享模块列表 + 导入同一 `AnnouncementPage` 的 `App.tsx`(完整 Vite/Node 打包仍是 Phase 5 TODO)。 ### 验收标准 diff --git a/scripts/org-export/README.md b/scripts/org-export/README.md new file mode 100644 index 00000000..aab0fdf3 --- /dev/null +++ b/scripts/org-export/README.md @@ -0,0 +1,22 @@ +# Organization standalone export (Phase 2 skeleton) + +`freeos org export-standalone --out dist/openxyos-web` writes a scaffold that +**imports the same org-ui pages** Dashboard mounts under `/organization/...`. + +Phase 2 includes **Announcements** only: + +| Delivery | Route | Component | +|---|---|---| +| Dashboard | `/organization/announcements` | `dashboard/src/org-ui` → `AnnouncementPage` | +| Standalone (this export) | `/announcements` | same import | + +Do not copy the page into a second tree. + +## Phase 5 TODO + +- Full Vite + minimal server packaging +- Local JWT IdentityBridge for customer self-host +- Remaining Open-12 pages after they land in `org-ui` +- Optional `packages/org-ui` extraction if the Dashboard Vite graph must be left behind + +Default FreeOS installers stay **zero-Node**. This export is an explicit operator action. diff --git a/src/octop/api/openapi_meta.py b/src/octop/api/openapi_meta.py index 89ba64af..55bf2579 100644 --- a/src/octop/api/openapi_meta.py +++ b/src/octop/api/openapi_meta.py @@ -162,7 +162,7 @@ }, { "name": "org-module", - "description": "Organization OS: FreeOS data plane ↔ openXYOS control plane, sidecar, and growth loop.", + "description": "Organization OS: FreeOS data plane ↔ openXYOS control plane, in-host announcements, sidecar, and growth loop.", }, { "name": "browser", diff --git a/src/octop/api/routers/org_announcements.py b/src/octop/api/routers/org_announcements.py new file mode 100644 index 00000000..b913f8f2 --- /dev/null +++ b/src/octop/api/routers/org_announcements.py @@ -0,0 +1,296 @@ +"""In-host announcements CRUD — openXYOS-shaped envelope, FreeOS JWT.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from octop.api.deps import current_user, get_server +from octop.i18n import tr +from octop.infra.server import OctopServer +from octop.infra.utils.locale import resolve_request_locale +from octop.modules.org_os.announcements.store import AnnouncementStore +from octop.modules.org_os.service import org_module_from_paths + +router = APIRouter() + + +class AnnouncementWriteBody(BaseModel): + title: str = "" + content: str = "" + type: str = "notice" + priority: str = "normal" + is_pinned: bool | int = 0 + expires_at: str | None = Field(default=None) + + +def _tenant_id(server: OctopServer) -> str: + return org_module_from_paths(server.paths).tenant_id() or "default" + + +def _store(server: OctopServer) -> AnnouncementStore: + return AnnouncementStore(server.paths.root) + + +def _locale(request: Request) -> str: + return resolve_request_locale(request) + + +def _fail(request: Request, key: str, status: int) -> JSONResponse: + return JSONResponse( + status_code=status, + content={"success": False, "error": tr(key, _locale(request))}, + ) + + +def _ok(data: Any = None) -> dict[str, Any]: + payload: dict[str, Any] = {"success": True} + if data is not None: + payload["data"] = data + return payload + + +def _can_publish(user: Any) -> bool: + return bool(getattr(user, "is_admin", False)) + + +def _creator_name(user: Any) -> str: + label = getattr(user, "label", None) + if isinstance(label, str) and label.strip(): + return label.strip() + return str(getattr(user, "username", "") or getattr(user, "display_name", "") or "") + + +def _total_users(server: OctopServer) -> int: + services = getattr(server, "services", None) + if services is None: + return 1 + repo = getattr(services, "user_repo", None) + if repo is None: + return 1 + try: + users = repo.list() + return max(1, len(users)) + except Exception: + return 1 + + +def _user_id(user: Any) -> int: + return int(getattr(user, "id", 0) or 0) + + +@router.get("/announcements", summary="List organization announcements") +async def list_announcements( + page: int = Query(default=1, ge=1), + limit: int = Query(default=20, ge=1, le=50), + type: str = Query(default="all"), + search: str = Query(default=""), + server: OctopServer = Depends(get_server), + user: Any = Depends(current_user), +) -> dict[str, Any]: + store = _store(server) + data = await asyncio.to_thread( + store.list_page, + tenant_id=_tenant_id(server), + user_id=_user_id(user), + page=page, + limit=limit, + type=type, + search=search, + total_users=_total_users(server), + ) + return _ok(data) + + +@router.get("/announcements/pinned", summary="List pinned announcements") +async def list_pinned_announcements( + server: OctopServer = Depends(get_server), + _user: Any = Depends(current_user), +) -> dict[str, Any]: + store = _store(server) + rows = await asyncio.to_thread(store.pinned, tenant_id=_tenant_id(server)) + return _ok(rows) + + +@router.get("/announcements/action/unread", summary="Unread announcement count") +async def unread_announcements( + server: OctopServer = Depends(get_server), + user: Any = Depends(current_user), +) -> dict[str, Any]: + store = _store(server) + count = await asyncio.to_thread( + store.unread_count, + tenant_id=_tenant_id(server), + user_id=_user_id(user), + ) + return _ok({"count": count}) + + +@router.post("/announcements/read-all", summary="Mark all announcements read") +async def read_all_announcements( + server: OctopServer = Depends(get_server), + user: Any = Depends(current_user), +) -> dict[str, Any]: + store = _store(server) + marked = await asyncio.to_thread( + store.mark_all_read, + tenant_id=_tenant_id(server), + user_id=_user_id(user), + ) + return _ok({"marked": marked}) + + +@router.get( + "/announcements/{announcement_id}", + summary="Announcement detail (marks read)", + response_model=None, +) +async def get_announcement( + announcement_id: int, + request: Request, + server: OctopServer = Depends(get_server), + user: Any = Depends(current_user), +) -> dict[str, Any] | JSONResponse: + store = _store(server) + tenant = _tenant_id(server) + row = await asyncio.to_thread(store.get, announcement_id, tenant_id=tenant) + if row is None: + return _fail(request, "org.announcements.not_found", 404) + uid = _user_id(user) + await asyncio.to_thread(store.mark_read, announcement_id, user_id=uid) + decorated = await asyncio.to_thread( + store.decorate, + row, + user_id=uid, + total_users=_total_users(server), + ) + return _ok(decorated) + + +@router.post( + "/announcements/{announcement_id}/read", + summary="Mark one announcement read", + response_model=None, +) +async def read_announcement( + announcement_id: int, + request: Request, + server: OctopServer = Depends(get_server), + user: Any = Depends(current_user), +) -> dict[str, Any] | JSONResponse: + store = _store(server) + row = await asyncio.to_thread(store.get, announcement_id, tenant_id=_tenant_id(server)) + if row is None: + return _fail(request, "org.announcements.not_found", 404) + await asyncio.to_thread(store.mark_read, announcement_id, user_id=_user_id(user)) + return _ok() + + +@router.post("/announcements", summary="Publish an announcement", response_model=None) +async def create_announcement( + body: AnnouncementWriteBody, + request: Request, + server: OctopServer = Depends(get_server), + user: Any = Depends(current_user), +) -> dict[str, Any] | JSONResponse: + if not _can_publish(user): + return _fail(request, "org.announcements.forbidden", 403) + title = body.title.strip() + content = body.content.strip() + if not title or not content: + return _fail(request, "org.announcements.title_content_required", 400) + store = _store(server) + row = await asyncio.to_thread( + store.create, + tenant_id=_tenant_id(server), + title=title, + content=content, + created_by=_user_id(user), + creator_name=_creator_name(user), + type=body.type, + priority=body.priority, + is_pinned=body.is_pinned, + expires_at=body.expires_at, + ) + return _ok(row) + + +@router.put( + "/announcements/{announcement_id}", + summary="Update an announcement", + response_model=None, +) +async def update_announcement( + announcement_id: int, + body: AnnouncementWriteBody, + request: Request, + server: OctopServer = Depends(get_server), + user: Any = Depends(current_user), +) -> dict[str, Any] | JSONResponse: + if not _can_publish(user): + return _fail(request, "org.announcements.forbidden", 403) + title = body.title.strip() + content = body.content.strip() + if not title or not content: + return _fail(request, "org.announcements.title_content_required", 400) + store = _store(server) + row = await asyncio.to_thread( + store.update, + announcement_id, + tenant_id=_tenant_id(server), + title=title, + content=content, + type=body.type, + priority=body.priority, + is_pinned=body.is_pinned, + expires_at=body.expires_at, + ) + if row is None: + return _fail(request, "org.announcements.not_found", 404) + return _ok(row) + + +@router.delete( + "/announcements/{announcement_id}", + summary="Soft-delete an announcement", + response_model=None, +) +async def delete_announcement( + announcement_id: int, + request: Request, + server: OctopServer = Depends(get_server), + user: Any = Depends(current_user), +) -> dict[str, Any] | JSONResponse: + if not _can_publish(user): + return _fail(request, "org.announcements.forbidden", 403) + store = _store(server) + deleted = await asyncio.to_thread( + store.soft_delete, announcement_id, tenant_id=_tenant_id(server) + ) + if not deleted: + return _fail(request, "org.announcements.not_found", 404) + return _ok() + + +@router.put( + "/announcements/{announcement_id}/toggle-pin", + summary="Toggle announcement pin", + response_model=None, +) +async def toggle_pin_announcement( + announcement_id: int, + request: Request, + server: OctopServer = Depends(get_server), + user: Any = Depends(current_user), +) -> dict[str, Any] | JSONResponse: + if not _can_publish(user): + return _fail(request, "org.announcements.forbidden", 403) + store = _store(server) + data = await asyncio.to_thread(store.toggle_pin, announcement_id, tenant_id=_tenant_id(server)) + if data is None: + return _fail(request, "org.announcements.not_found", 404) + return _ok(data) diff --git a/src/octop/api/routers/org_module.py b/src/octop/api/routers/org_module.py index 2cb37772..d15824d1 100644 --- a/src/octop/api/routers/org_module.py +++ b/src/octop/api/routers/org_module.py @@ -10,6 +10,7 @@ from pydantic import BaseModel, Field from octop.api.deps import current_user, get_server, require_permission +from octop.api.routers.org_announcements import router as announcements_router from octop.infra.server import OctopServer from octop.infra.utils.locale import resolve_request_locale from octop.modules.org_os.catalog import OPENXYOS_MODULES @@ -33,6 +34,7 @@ from octop.modules.org_os.source_download import download_openxyos_source router = APIRouter() +router.include_router(announcements_router) class OrgModulePatch(BaseModel): diff --git a/src/octop/cli/commands/org.py b/src/octop/cli/commands/org.py index 7283568f..61a9851a 100644 --- a/src/octop/cli/commands/org.py +++ b/src/octop/cli/commands/org.py @@ -373,6 +373,26 @@ def assets_apply(pack_dir: Path | None, tenant_id: str, base_url: str) -> None: click.echo(json.dumps(result.to_dict(), indent=2)) +@org.command("export-standalone") +@click.option( + "--out", + "out_dir", + type=click.Path(path_type=Path), + required=True, + help="Output directory for the standalone web scaffold.", +) +def export_standalone(out_dir: Path) -> None: + """Scaffold a standalone Organization web from shared org-ui (Phase 2: Announcements). + + Writes the shared module list and an App.tsx that imports AnnouncementPage. + Full Vite + Node packaging is Phase 5 (TODO in the generated README). + """ + from octop.modules.org_os.export_standalone import write_standalone_scaffold + + written = write_standalone_scaffold(out_dir) + click.echo(json.dumps(written, indent=2)) + + @org.group("loop") def org_loop() -> None: """Finished self-growth loop: produce → assemble → feed back → govern.""" diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index dbead1f9..496689e1 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -756,6 +756,12 @@ "imported_plugin": "imported openXYOS plugin {slug}", "imported_mcp": "imported openXYOS MCP {slug}" }, + "announcements": { + "not_found": "Announcement not found or deleted", + "forbidden": "Permission denied", + "title_content_required": "Title and content are required", + "unavailable": "Announcement service is temporarily unavailable. Please try again" + }, "sidecar": { "already": "sidecar already reachable", "started_pending": "started but not reachable yet; check logs/org-sidecar.log", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index 7d50752c..3c7eabe1 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -756,6 +756,12 @@ "imported_plugin": "已导入 openXYOS 插件 {slug}", "imported_mcp": "已导入 openXYOS MCP {slug}" }, + "announcements": { + "not_found": "公告不存在或已删除", + "forbidden": "无权限", + "title_content_required": "标题和内容不能为空", + "unavailable": "公告服务暂时不可用,请稍后重试" + }, "sidecar": { "already": "边车已可达", "started_pending": "已启动但尚未可达;请查看 logs/org-sidecar.log", diff --git a/src/octop/modules/org_os/announcements/__init__.py b/src/octop/modules/org_os/announcements/__init__.py new file mode 100644 index 00000000..8f14c9c8 --- /dev/null +++ b/src/octop/modules/org_os/announcements/__init__.py @@ -0,0 +1,13 @@ +"""In-host organization announcements (Phase 2 org-ui slice).""" + +from octop.modules.org_os.announcements.store import ( + ANNOUNCEMENT_PRIORITIES, + ANNOUNCEMENT_TYPES, + AnnouncementStore, +) + +__all__ = [ + "ANNOUNCEMENT_PRIORITIES", + "ANNOUNCEMENT_TYPES", + "AnnouncementStore", +] diff --git a/src/octop/modules/org_os/announcements/store.py b/src/octop/modules/org_os/announcements/store.py new file mode 100644 index 00000000..4f056008 --- /dev/null +++ b/src/octop/modules/org_os/announcements/store.py @@ -0,0 +1,377 @@ +"""SQLite announcements store under ``{FREEOS_HOME}/org/`` (not sidecar SQL.js).""" + +from __future__ import annotations + +import sqlite3 +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +ANNOUNCEMENT_TYPES = ("notice", "policy", "news", "emergency") +ANNOUNCEMENT_PRIORITIES = ("low", "normal", "important", "urgent") + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS announcements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'notice', + priority TEXT NOT NULL DEFAULT 'normal', + is_pinned INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + created_by INTEGER NOT NULL, + creator_name TEXT, + published_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT, + deleted_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_announcements_tenant + ON announcements (tenant_id, deleted_at, is_pinned, published_at); +CREATE TABLE IF NOT EXISTS announcement_reads ( + announcement_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + read_at TEXT NOT NULL, + PRIMARY KEY (announcement_id, user_id) +); +""" + + +def utc_now() -> str: + return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def normalize_type(value: str | None) -> str: + raw = (value or "notice").strip().lower() + return raw if raw in ANNOUNCEMENT_TYPES else "notice" + + +def normalize_priority(value: str | None) -> str: + raw = (value or "normal").strip().lower() + return raw if raw in ANNOUNCEMENT_PRIORITIES else "normal" + + +def _as_pinned(value: Any) -> int: + if value in (True, 1, "1", "true", "True"): + return 1 + return 0 + + +class AnnouncementStore: + """Tenant-scoped announcements + per-user reads in one org SQLite file.""" + + def __init__(self, home: Path) -> None: + self.home = home + self.path = home / "org" / "announcements.sqlite" + self.path.parent.mkdir(parents=True, exist_ok=True) + self._init() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + return conn + + def _init(self) -> None: + with self._connect() as conn: + conn.executescript(_SCHEMA) + + def create( + self, + *, + tenant_id: str, + title: str, + content: str, + created_by: int, + creator_name: str, + type: str = "notice", + priority: str = "normal", + is_pinned: Any = 0, + expires_at: str | None = None, + ) -> dict[str, Any]: + now = utc_now() + with self._connect() as conn: + cur = conn.execute( + """ + INSERT INTO announcements ( + tenant_id, title, content, type, priority, is_pinned, + expires_at, created_by, creator_name, published_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + tenant_id, + title, + content, + normalize_type(type), + normalize_priority(priority), + _as_pinned(is_pinned), + expires_at or None, + created_by, + creator_name, + now, + now, + ), + ) + row_id = int(cur.lastrowid or 0) + row = self.get(row_id, tenant_id=tenant_id) + assert row is not None + return row + + def get(self, announcement_id: int, *, tenant_id: str) -> dict[str, Any] | None: + with self._connect() as conn: + row = conn.execute( + """ + SELECT * FROM announcements + WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL + """, + (announcement_id, tenant_id), + ).fetchone() + return dict(row) if row is not None else None + + def list_page( + self, + *, + tenant_id: str, + user_id: int, + page: int = 1, + limit: int = 20, + type: str = "all", + search: str = "", + total_users: int = 1, + ) -> dict[str, Any]: + page = max(1, page) + limit = min(max(1, limit), 50) + offset = (page - 1) * limit + where = ["tenant_id = ?", "deleted_at IS NULL"] + params: list[Any] = [tenant_id] + if type and type != "all": + where.append("type = ?") + params.append(normalize_type(type)) + if search.strip(): + like = f"%{search.strip()}%" + where.append("(title LIKE ? OR content LIKE ?)") + params.extend([like, like]) + clause = " AND ".join(where) + with self._connect() as conn: + total = int( + conn.execute( + f"SELECT COUNT(*) FROM announcements WHERE {clause}", + params, + ).fetchone()[0] + ) + rows = conn.execute( + f""" + SELECT * FROM announcements + WHERE {clause} + ORDER BY is_pinned DESC, published_at DESC, id DESC + LIMIT ? OFFSET ? + """, + [*params, limit, offset], + ).fetchall() + ids = [int(r["id"]) for r in rows] + reads = self._read_set(conn, user_id, ids) + counts = self._read_counts(conn, ids) + users = max(1, total_users) + items = [ + self._decorate(dict(row), reads=reads, counts=counts, total_users=users) for row in rows + ] + return {"list": items, "total": total, "page": page, "limit": limit} + + def pinned(self, *, tenant_id: str, limit: int = 5) -> list[dict[str, Any]]: + now = utc_now() + with self._connect() as conn: + rows = conn.execute( + """ + SELECT * FROM announcements + WHERE tenant_id = ? AND deleted_at IS NULL AND is_pinned = 1 + AND (expires_at IS NULL OR expires_at > ?) + ORDER BY published_at DESC + LIMIT ? + """, + (tenant_id, now, limit), + ).fetchall() + return [dict(row) for row in rows] + + def unread_count(self, *, tenant_id: str, user_id: int) -> int: + with self._connect() as conn: + raw = conn.execute( + """ + SELECT COUNT(*) FROM announcements a + WHERE a.tenant_id = ? AND a.deleted_at IS NULL + AND a.id NOT IN ( + SELECT announcement_id FROM announcement_reads WHERE user_id = ? + ) + """, + (tenant_id, user_id), + ).fetchone()[0] + return int(raw) + + def mark_read(self, announcement_id: int, *, user_id: int) -> None: + now = utc_now() + with self._connect() as conn: + conn.execute( + """ + INSERT OR IGNORE INTO announcement_reads (announcement_id, user_id, read_at) + VALUES (?, ?, ?) + """, + (announcement_id, user_id, now), + ) + + def mark_all_read(self, *, tenant_id: str, user_id: int) -> int: + now = utc_now() + with self._connect() as conn: + unread = conn.execute( + """ + SELECT a.id FROM announcements a + WHERE a.tenant_id = ? AND a.deleted_at IS NULL + AND a.id NOT IN ( + SELECT announcement_id FROM announcement_reads WHERE user_id = ? + ) + """, + (tenant_id, user_id), + ).fetchall() + for row in unread: + conn.execute( + """ + INSERT OR IGNORE INTO announcement_reads (announcement_id, user_id, read_at) + VALUES (?, ?, ?) + """, + (int(row["id"]), user_id, now), + ) + return len(unread) + + def update( + self, + announcement_id: int, + *, + tenant_id: str, + title: str, + content: str, + type: str, + priority: str, + is_pinned: Any, + expires_at: str | None, + ) -> dict[str, Any] | None: + existing = self.get(announcement_id, tenant_id=tenant_id) + if existing is None: + return None + now = utc_now() + with self._connect() as conn: + conn.execute( + """ + UPDATE announcements + SET title = ?, content = ?, type = ?, priority = ?, + is_pinned = ?, expires_at = ?, updated_at = ? + WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL + """, + ( + title, + content, + normalize_type(type), + normalize_priority(priority), + _as_pinned(is_pinned), + expires_at or None, + now, + announcement_id, + tenant_id, + ), + ) + return self.get(announcement_id, tenant_id=tenant_id) + + def soft_delete(self, announcement_id: int, *, tenant_id: str) -> bool: + existing = self.get(announcement_id, tenant_id=tenant_id) + if existing is None: + return False + now = utc_now() + with self._connect() as conn: + conn.execute( + """ + UPDATE announcements SET deleted_at = ? + WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL + """, + (now, announcement_id, tenant_id), + ) + return True + + def toggle_pin(self, announcement_id: int, *, tenant_id: str) -> dict[str, Any] | None: + existing = self.get(announcement_id, tenant_id=tenant_id) + if existing is None: + return None + new_pin = 0 if int(existing.get("is_pinned") or 0) else 1 + now = utc_now() + with self._connect() as conn: + conn.execute( + """ + UPDATE announcements SET is_pinned = ?, updated_at = ? + WHERE id = ? AND tenant_id = ? + """, + (new_pin, now, announcement_id, tenant_id), + ) + return {"is_pinned": new_pin == 1} + + def decorate( + self, + row: dict[str, Any], + *, + user_id: int, + total_users: int, + ) -> dict[str, Any]: + with self._connect() as conn: + announcement_id = int(row["id"]) + reads = self._read_set(conn, user_id, [announcement_id]) + counts = self._read_counts(conn, [announcement_id]) + return self._decorate( + row, + reads=reads, + counts=counts, + total_users=max(1, total_users), + ) + + def _read_set(self, conn: sqlite3.Connection, user_id: int, ids: list[int]) -> set[int]: + if not ids: + return set() + placeholders = ",".join("?" for _ in ids) + rows = conn.execute( + f""" + SELECT announcement_id FROM announcement_reads + WHERE user_id = ? AND announcement_id IN ({placeholders}) + """, + [user_id, *ids], + ).fetchall() + return {int(r["announcement_id"]) for r in rows} + + def _read_counts(self, conn: sqlite3.Connection, ids: list[int]) -> dict[int, int]: + if not ids: + return {} + placeholders = ",".join("?" for _ in ids) + rows = conn.execute( + f""" + SELECT announcement_id, COUNT(*) AS count + FROM announcement_reads + WHERE announcement_id IN ({placeholders}) + GROUP BY announcement_id + """, + ids, + ).fetchall() + return {int(r["announcement_id"]): int(r["count"]) for r in rows} + + def _decorate( + self, + row: dict[str, Any], + *, + reads: set[int], + counts: dict[int, int], + total_users: int, + ) -> dict[str, Any]: + announcement_id = int(row["id"]) + read_count = counts.get(announcement_id, 0) + users = max(1, total_users) + out = dict(row) + out["is_read"] = announcement_id in reads + out["read_count"] = read_count + out["total_users"] = users + out["read_percent"] = int(round((read_count / users) * 100)) + out["is_pinned"] = int(out.get("is_pinned") or 0) + return out diff --git a/src/octop/modules/org_os/contract.py b/src/octop/modules/org_os/contract.py new file mode 100644 index 00000000..2d09a7ad --- /dev/null +++ b/src/octop/modules/org_os/contract.py @@ -0,0 +1,19 @@ +"""Shared org-ui / org-contract keys (Phase 2 seam; not a separate package yet).""" + +from __future__ import annotations + +from octop.modules.org_os.catalog import catalog_keys + +# Pages extracted into dashboard/src/org-ui and listed by export-standalone. +SHARED_ORG_UI_MODULES: tuple[str, ...] = ("announcements",) + + +def shared_org_ui_modules() -> list[str]: + return list(SHARED_ORG_UI_MODULES) + + +def assert_shared_modules_in_catalog() -> None: + keys = set(catalog_keys()) + missing = [key for key in SHARED_ORG_UI_MODULES if key not in keys] + if missing: + raise ValueError(f"shared org-ui modules missing from catalog: {missing}") diff --git a/src/octop/modules/org_os/export_standalone.py b/src/octop/modules/org_os/export_standalone.py new file mode 100644 index 00000000..44b752cd --- /dev/null +++ b/src/octop/modules/org_os/export_standalone.py @@ -0,0 +1,135 @@ +"""Minimal ``freeos org export-standalone`` scaffold (Phase 2). + +Full Vite + Node server packaging is Phase 5. This writes a snapshot that +lists the shared org-ui modules and points at the same AnnouncementPage +source Dashboard mounts under ``/organization/announcements``. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from octop.modules.org_os.contract import SHARED_ORG_UI_MODULES + +_README = """# Standalone Organization web (export skeleton) + +This directory is produced by `freeos org export-standalone`. + +## Shared UI (single source) + +Dashboard and this export consume the **same** org-ui pages: + +| Module | Dashboard route | Standalone route | Import | +|---|---|---|---| +| announcements | `/organization/announcements` | `/announcements` | `dashboard/src/org-ui` → `AnnouncementPage` | + +Do **not** copy `AnnouncementPage.tsx` into a second tree. Edit `dashboard/src/org-ui`. + +## Phase 2 vs Phase 5 + +Phase 2 (this scaffold): + +- Shared module list (`src/modules.json`) +- Thin `src/App.tsx` that imports `AnnouncementPage` +- Documents IdentityBridge: embedded mode uses FreeOS JWT; standalone uses local JWT + +Phase 5 (TODO — not implemented here): + +- Full Vite + minimal server packaging +- Commercial `App.tsx` routes +- Independent `packages/org-ui` extraction if the Dashboard Vite graph must be left behind +- Default installer stays zero-Node; this export is an explicit operator action + +## Next + +```bash +# from a FreeOS checkout +uv run freeos org export-standalone --out dist/openxyos-web +# then (Phase 5) npm install && npm run build inside the out dir +``` +""" + +_APP_TSX = """\ +import { AnnouncementPage, SHARED_ORG_UI_MODULES } from "org-ui"; +import type { OrgAnnouncementsClient, OrgSession } from "org-ui"; + +/** + * Standalone shell stub. Phase 5 wires a real IdentityBridge + local JWT. + * The page component is the same one Dashboard mounts at + * /organization/announcements. + */ +const session: OrgSession = { + userId: 0, + displayName: "standalone", + role: "admin", + isAdmin: true, +}; + +export default function App(props: { + client: OrgAnnouncementsClient; + locale?: "zh" | "en"; +}) { + return ( + + ); +} +""" + +_PACKAGE_JSON = { + "name": "openxyos-web", + "private": True, + "version": "0.0.0", + "description": ( + "Standalone Organization web export skeleton. " + "Pages come from dashboard/src/org-ui (Phase 2: Announcements). " + "Full Node packaging is Phase 5." + ), + "type": "module", + "scripts": { + "dev": "echo 'TODO Phase 5: Vite + server packaging'", + "build": "echo 'TODO Phase 5: Vite + server packaging'", + }, + "peerDependencies": { + "antd": "^5", + "react": "^18", + "react-dom": "^18", + }, +} + + +def write_standalone_scaffold(out_dir: Path) -> dict[str, Any]: + dest = Path(out_dir) + dest.mkdir(parents=True, exist_ok=True) + src = dest / "src" + src.mkdir(parents=True, exist_ok=True) + + modules = { + "shared_org_ui_modules": list(SHARED_ORG_UI_MODULES), + "import": "dashboard/src/org-ui", + "pages": { + "announcements": { + "component": "AnnouncementPage", + "embedded_route": "/organization/announcements", + "standalone_route": "/announcements", + "api": "/api/org-module/announcements", + } + }, + "todo": "Phase 5: full Vite + server packaging; do not fork AnnouncementPage", + } + (dest / "README.md").write_text(_README, encoding="utf-8") + (dest / "package.json").write_text(json.dumps(_PACKAGE_JSON, indent=2) + "\n", encoding="utf-8") + (src / "modules.json").write_text(json.dumps(modules, indent=2) + "\n", encoding="utf-8") + (src / "App.tsx").write_text(_APP_TSX, encoding="utf-8") + return { + "out_dir": str(dest), + "modules": list(SHARED_ORG_UI_MODULES), + "files": ["README.md", "package.json", "src/modules.json", "src/App.tsx"], + "todo": "Phase 5: full Vite + server packaging", + } diff --git a/tests/unit/cli/test_org_export_standalone.py b/tests/unit/cli/test_org_export_standalone.py new file mode 100644 index 00000000..12e3f230 --- /dev/null +++ b/tests/unit/cli/test_org_export_standalone.py @@ -0,0 +1,29 @@ +"""``freeos org export-standalone`` writes the shared Announcements seam.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from click.testing import CliRunner + +from octop.cli.main import cli + + +def test_export_standalone_lists_announcements(tmp_path: Path) -> None: + out = tmp_path / "openxyos-web" + runner = CliRunner() + result = runner.invoke(cli, ["org", "export-standalone", "--out", str(out)]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["modules"] == ["announcements"] + modules = json.loads((out / "src" / "modules.json").read_text(encoding="utf-8")) + assert modules["shared_org_ui_modules"] == ["announcements"] + assert modules["pages"]["announcements"]["component"] == "AnnouncementPage" + assert modules["pages"]["announcements"]["embedded_route"] == "/organization/announcements" + app = (out / "src" / "App.tsx").read_text(encoding="utf-8") + assert "AnnouncementPage" in app + assert 'from "org-ui"' in app + readme = (out / "README.md").read_text(encoding="utf-8") + assert "Phase 5" in readme + assert "AnnouncementPage" in readme diff --git a/tests/unit/test_org_announcements.py b/tests/unit/test_org_announcements.py new file mode 100644 index 00000000..bec2c9a7 --- /dev/null +++ b/tests/unit/test_org_announcements.py @@ -0,0 +1,188 @@ +"""In-host announcements store and /api/org-module/announcements.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from octop.api.deps import current_user, get_server +from octop.api.routers.org_announcements import router as announcements_router +from octop.api.routers.org_module import router +from octop.infra.users.identity import Role, User +from octop.modules.org_os.announcements.store import AnnouncementStore +from octop.modules.org_os.contract import ( + SHARED_ORG_UI_MODULES, + assert_shared_modules_in_catalog, +) + + +def _admin() -> User: + return User(id=1, username="admin", role=Role.ADMIN, display_name="Ada") + + +def _member() -> User: + return User(id=2, username="member", role=Role.USER, display_name="Mo") + + +def _client(tmp_path: Path, user: User) -> TestClient: + app = FastAPI() + app.include_router(router, prefix="/api/org-module") + server = SimpleNamespace( + paths=SimpleNamespace(config=tmp_path / "config.json", root=tmp_path), + services=None, + ) + app.dependency_overrides[current_user] = lambda: user + app.dependency_overrides[get_server] = lambda: server + return TestClient(app) + + +def test_shared_announcements_module_is_in_catalog() -> None: + assert "announcements" in SHARED_ORG_UI_MODULES + assert_shared_modules_in_catalog() + + +def test_store_create_list_read(tmp_path: Path) -> None: + store = AnnouncementStore(tmp_path) + row = store.create( + tenant_id="default", + title="Hello", + content="World", + created_by=1, + creator_name="Ada", + type="notice", + is_pinned=True, + ) + assert row["id"] == 1 + assert row["is_pinned"] == 1 + listed = store.list_page(tenant_id="default", user_id=2, total_users=2) + assert listed["total"] == 1 + item = listed["list"][0] + assert item["is_read"] is False + assert item["title"] == "Hello" + store.mark_read(1, user_id=2) + listed = store.list_page(tenant_id="default", user_id=2, total_users=2) + assert listed["list"][0]["is_read"] is True + assert listed["list"][0]["read_count"] == 1 + assert listed["list"][0]["read_percent"] == 50 + assert store.unread_count(tenant_id="default", user_id=2) == 0 + + +def test_store_search_and_soft_delete(tmp_path: Path) -> None: + store = AnnouncementStore(tmp_path) + store.create( + tenant_id="default", + title="Policy A", + content="Keep secrets", + created_by=1, + creator_name="Ada", + type="policy", + ) + store.create( + tenant_id="default", + title="News B", + content="Ship it", + created_by=1, + creator_name="Ada", + type="news", + ) + found = store.list_page(tenant_id="default", user_id=1, search="secrets") + assert found["total"] == 1 + assert found["list"][0]["title"] == "Policy A" + store.soft_delete(found["list"][0]["id"], tenant_id="default") + assert store.list_page(tenant_id="default", user_id=1)["total"] == 1 + + +def test_store_isolates_tenants(tmp_path: Path) -> None: + store = AnnouncementStore(tmp_path) + store.create( + tenant_id="a", + title="A", + content="one", + created_by=1, + creator_name="Ada", + ) + store.create( + tenant_id="b", + title="B", + content="two", + created_by=1, + creator_name="Ada", + ) + assert store.list_page(tenant_id="a", user_id=1)["total"] == 1 + assert store.get(1, tenant_id="b") is None + + +def test_api_list_create_mark_read(tmp_path: Path) -> None: + admin = _client(tmp_path, _admin()) + created = admin.post( + "/api/org-module/announcements", + json={"title": "All hands", "content": "Friday 4pm", "type": "notice"}, + ) + assert created.status_code == 200 + body = created.json() + assert body["success"] is True + announcement_id = body["data"]["id"] + + listed = admin.get("/api/org-module/announcements") + assert listed.json()["data"]["total"] == 1 + assert listed.json()["data"]["list"][0]["title"] == "All hands" + + member = _client(tmp_path, _member()) + unread = member.get("/api/org-module/announcements/action/unread") + assert unread.json()["data"]["count"] == 1 + detail = member.get(f"/api/org-module/announcements/{announcement_id}") + assert detail.status_code == 200 + assert detail.json()["data"]["is_read"] is True + unread = member.get("/api/org-module/announcements/action/unread") + assert unread.json()["data"]["count"] == 0 + + +def test_api_member_cannot_publish(tmp_path: Path) -> None: + member = _client(tmp_path, _member()) + response = member.post( + "/api/org-module/announcements", + json={"title": "Nope", "content": "denied"}, + ) + assert response.status_code == 403 + assert response.json()["success"] is False + + +def test_api_create_requires_title(tmp_path: Path) -> None: + admin = _client(tmp_path, _admin()) + response = admin.post( + "/api/org-module/announcements", + json={"title": " ", "content": "x"}, + ) + assert response.status_code == 400 + + +def test_api_update_pin_delete(tmp_path: Path) -> None: + admin = _client(tmp_path, _admin()) + created = admin.post( + "/api/org-module/announcements", + json={"title": "Pin me", "content": "body"}, + ) + announcement_id = created.json()["data"]["id"] + updated = admin.put( + f"/api/org-module/announcements/{announcement_id}", + json={"title": "Pinned", "content": "body", "is_pinned": 1}, + ) + assert updated.json()["data"]["title"] == "Pinned" + pinned = admin.put( + f"/api/org-module/announcements/{announcement_id}/toggle-pin", + ) + assert pinned.json()["data"]["is_pinned"] is False + deleted = admin.delete(f"/api/org-module/announcements/{announcement_id}") + assert deleted.status_code == 200 + missing = admin.get(f"/api/org-module/announcements/{announcement_id}") + assert missing.status_code == 404 + + +def test_announcement_routes_registered() -> None: + paths = {getattr(route, "path", "") for route in announcements_router.routes} + assert "/announcements" in paths + assert "/announcements/action/unread" in paths + assert "/announcements/{announcement_id}" in paths