diff --git a/docs/IM-第一二章后续增强项.md b/docs/IM-第一二章后续增强项.md index 0f274b8..265a212 100644 --- a/docs/IM-第一二章后续增强项.md +++ b/docs/IM-第一二章后续增强项.md @@ -1,7 +1,7 @@ -# IM 第一至六章后续增强项 +# IM 第一至九章后续增强项 > 日期:2026-08-13 -> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章群消息与 @、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理 +> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章群消息与 @、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构 > 用途:记录当前已完成基线之外的增强项,后续统一排期处理。 ## 1. 第一章:登录、注册与安全 @@ -167,9 +167,109 @@ | P1 | 群解散与二维码吊销 | 群解散、二维码撤销、邀请失效需要状态联动和权限回收。 | 解散后 Topic、二维码、邀请、文件新增权限全部失效。 | | P2 | 大群性能与稳定分页 | 成员、申请、公告、禁言、文件列表都需要稳定游标和大群压测。 | 大群场景下分页不重复不漏,接口延迟达标。 | -## 7. 统一处理建议 +## 7. 第七章:文件下载、收藏管理与聊天记录检索 -1. 先做 P0:统一幂等、实时事件/Outbox、验证码真实发送与限频、P2P/好友/搜索隐私策略、第六章群业务独立表与事务状态机。 -2. 再做 P1:租户策略配置、系统通知生产通道、实名审核、设备管理、资料隐私裁剪、关系审计、群禁言发送校验和群文件索引。 -3. 最后做 P2:端到端重连测试、schema 版本演进、大群压测、批量接口体验优化。 +### 7.1 当前已完成基线 + +1. 收藏创建、列表、详情和删除接口已具备,支持文本、文件、语音、链接等类型。 +2. 收藏批量创建和基础分享前的数据结构已具备。 +3. Topic 消息关键词搜索、文件/图片/视频等媒体类型检索接口已具备。 +4. 消息上下文定位接口可围绕指定 seq 返回前后消息。 +5. 文件访问已接入用户与 Topic 权限校验,下载前可判断资源可见性。 + +### 7.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | 收藏幂等与去重策略 | 收藏同一消息、同一文件或同一链接需要统一去重和幂等键处理。 | 重复点击收藏不会产生重复记录;超时重试返回同一收藏 ID。 | +| P0 | 收藏分享权限二次校验 | 收藏文件或消息分享给好友/群时,需要重新校验接收方是否可访问原资源或生成受控副本。 | 无权限接收方不能通过收藏分享拿到原会话附件。 | +| P0 | 文件下载短时签名 URL | 当前权限判断已具备,后续应生成短时下载 URL,并记录下载审计。 | URL 过期不可访问;退群/拉黑后旧 URL 失效或受策略限制。 | +| P0 | 文件安全扫描与 MIME 校验 | 上传后的文件需要扩展名、MIME、内容嗅探、病毒扫描和图片/视频解码安全检查。 | 高风险文件不可下载或转发,客户端收到稳定错误码。 | +| P1 | 收藏分类与全文索引 | 收藏列表需要按类型、来源、时间、关键词、发送人等维度检索。 | 大量收藏下搜索响应稳定,类型筛选结果准确。 | +| P1 | 聊天记录稳定游标 | 消息搜索和媒体列表需要稳定游标,避免新消息插入导致翻页重复或漏项。 | 搜索期间新增消息不影响当前翻页结果。 | +| P1 | 历史消息权限快照策略 | 用户退群、删除好友、拉黑后,历史消息和附件的可见范围需要统一规则。 | 各入口对同一历史资源权限判断一致。 | +| P1 | 下载状态与断点续传 | 大文件下载需要断点续传、失败重试、进度状态和下载完成事件。 | 网络中断后可续传;下载完成状态可跨端同步或按端隔离。 | +| P2 | 聊天记录高亮与命中片段 | 搜索结果可返回命中摘要、高亮范围和消息类型摘要。 | 客户端无需二次解析即可展示命中片段。 | +| P2 | 收藏导出与批量管理 | 后续可支持收藏批量删除、批量转发、导出和云端容量统计。 | 批量操作有部分失败语义和可重试结果。 | + +## 8. 第八章:实时语音与视频通话 + +### 8.1 当前已完成基线 + +1. 通话 invite、accept、offer、answer、ice-candidate、hang-up 等事件沿用 Tinode call event 链路。 +2. 通话记录表 `im_call_records` 已具备,记录 `call_id`、Topic、seq、主叫/被叫、类型、状态、时长和结束原因。 +3. 通话记录创建、接听和结束状态更新已接入 store/adapter。 +4. 通话记录查询接口已具备:全局 `GET /v1/im/calls` 与 Topic 内 `GET /v1/im/topics/{topic}/calls`。 +5. 查询结果返回对端用户、方向、通话类型、状态、时长和关键时间点,客户端可先完成通话记录页对接。 + +### 8.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | 跨设备抢答状态机 | 同一账号多端收到来电时,需要一个设备接听后自动取消其他端待接状态。 | A 手机接听后,A 平板和 Web 端立即变为已在其他设备接听。 | +| P0 | 通话状态强一致与幂等 | invite、accept、hang-up 等事件需要幂等键或事件序号,避免乱序/重复导致状态回退。 | 重复 hang-up 不改变最终状态;迟到 accept 不会覆盖已结束通话。 | +| P0 | 通话事件 Outbox 与通知 | 来电、未接、挂断、忙线等需要可信事件和 Push,DB 状态与通知通过 Outbox 衔接。 | 离线用户收到一次来电/未接提醒,失败可补偿。 | +| P0 | TURN/STUN 配置与媒体准入 | 服务端应按租户/区域返回可用 ICE 配置,并限制未授权用户创建媒体会话。 | 客户端拉取到有效 TURN;无权限通话被服务端拒绝。 | +| P1 | 结束原因标准化 | 需要统一 `missed`、`declined`、`busy`、`timeout`、`network_error` 等原因和客户端展示文案。 | 不同端同一结束原因展示一致,统计口径一致。 | +| P1 | 通话质量指标 | 需要采集建立耗时、ICE 成功率、丢包、抖动、断连等指标。 | 后台可按租户和版本查看通话质量趋势。 | +| P1 | 群通话/会议房间拆分 | 群音视频不宜复用 P2P call,需要会议房间、参与人状态和 SFU 接入。 | 群通话有独立 room_id、成员列表和退出/重进状态。 | +| P1 | 通话权限统一策略 | 通话入口应复用 P2P/群关系策略、黑名单、禁言、青少年模式和企业策略。 | 被拉黑、非好友或受限制用户无法发起通话。 | +| P2 | 通话记录聚合与清理 | 通话记录可按会话、日期、未接分类聚合,并支持保留周期清理。 | 记录列表分页稳定,超过保留期按策略清理。 | + +## 9. 第九章:企业工作台与办公协同 + +### 9.1 当前已完成基线 + +1. 工作台接口 `GET /v1/im/workbench` 已具备,返回模块、布局版本和企业功能开关。 +2. 工作台模块表 `im_workbench_apps` 已具备,默认包含组织架构、会议通知、日报、月报、总经理信箱入口。 +3. 服务端对工作台 route 做内部白名单过滤,避免客户端执行任意服务端 URL。 +4. 组织架构部门表 `im_org_departments` 和成员表 `im_org_members` 已具备。 +5. 部门列表 `GET /v1/im/organization/departments`、部门成员 `GET /v1/im/organization/departments/{id}/members`、组织搜索 `GET /v1/im/organization/search` 已具备。 +6. 会议通知 `POST/GET/PATCH/CANCEL/REMIND/MY-RESPONSE` 已具备基线,包含参与人和版本冲突控制。 +7. 日报/月报草稿、提交、详情、列表、统计与提醒基线已具备。 +8. 总经理信箱配置与提交接口已具备,包含匿名策略、文案版本和幂等键。 + +### 9.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | 工作台权限与功能开关配置化 | 当前模块基线可返回,后续需要按租户、部门、角色、后台配置动态裁剪。 | 不同用户登录同一租户看到不同授权模块。 | +| P0 | 工作台未读角标事件 | 文档要求 `workbench.badge.updated`,后续需要模块未读聚合和实时增量事件。 | 会议、日报、信箱未读变化后工作台角标实时更新。 | +| P0 | 组织数据后台维护链路 | 当前前台读接口和表已具备,后续需要后台导入、增删改部门、调整成员和离职状态。 | 后台修改组织后前台接口版本变化,客户端可刷新。 | +| P0 | 组织权限与可见字段裁剪 | 成员手机号、邮箱、职位等字段需要按组织角色、隐私和企业策略裁剪。 | 普通员工和管理员看到的成员字段不同且不可绕过。 | +| P1 | 组织搜索索引优化 | 当前基础 LIKE 搜索可用,后续需要拼音、工号、手机号脱敏匹配和稳定排序。 | 大组织下搜索延迟达标,结果排序符合组织规则。 | +| P1 | 组织版本与增量同步 | 部门和成员返回版本,后续需要全局组织版本、变更事件和客户端增量刷新。 | 切换部门或人员变更后客户端无需全量重拉。 | +| P1 | 会议通知实时提醒 | 当前接口和表已具备,后续需要会议创建、更新、取消、响应后的在线事件和 Push。 | 参与人在线端无需刷新即可收到会议变化。 | +| P1 | 日报/月报后台统计口径 | 当前基础统计可用,后续需要按部门、周期、迟交、补交、未读等维度扩展。 | 管理员看板与业务口径一致。 | +| P1 | 总经理信箱后台流转 | 当前前台配置/提交已具备,后续需要后台处理、回复、转派、归档和通知。 | 信件全生命周期可追踪,匿名身份受保护。 | +| P1 | 办公协同审计 | 组织查询、会议变更、报告查看、信箱提交等敏感动作需要审计。 | 后台可按租户、用户、对象查询办公操作流水。 | +| P2 | 办公模块统一附件策略 | 会议、日报、月报、信箱附件需要复用第七章文件 ACL、用途和病毒扫描。 | 附件上传、查看、下载权限与业务状态一致。 | + +## 10. 个人设置与客户端能力 + +### 10.1 当前已完成基线 + +1. 个人资料 `GET/PATCH /v1/im/me/profile`、个人页 `GET /v1/im/me` 已具备。 +2. 隐私设置 `GET/PATCH /v1/im/me/privacy` 已具备,支持版本冲突控制。 +3. 最新版本检查 `GET /v1/im/app/releases/latest` 已具备,支持未登录按租户解析调用。 +4. 青年模式查询、开启、关闭接口已具备,复用敏感操作 `action_token` 和独立密码校验链路。 +5. 设备 Push Token 覆盖写入 `PUT /v1/im/devices/{device_id}/push-token` 已具备。 + +### 10.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | 个人资料变更实时同步 | 资料、头像、隐私变更后需要向多端推送 `profile.updated`、`privacy.updated`。 | 同账号多端无需重登即可刷新设置。 | +| P0 | 精准登出与会话撤销 | 现有 token 主要靠 `auth_version` 批量失效,后续需要 access/refresh/session 表支持当前设备登出和指定 session 删除。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 | +| P0 | 青年模式策略配置化 | 当前返回默认 restrictions,后续需要后台按租户配置可用时段、内容限制、消费保护等规则。 | 不同租户返回不同限制策略且能热更新。 | +| P0 | Push Token 生命周期 | 当前可覆盖写入,后续需要 token 失效回收、设备解绑、平台厂商错误码处理。 | 推送失败后自动禁用无效 token,重新登录可恢复。 | +| P1 | 版本灰度发布 | 当前按 platform/channel 查最新版本,后续需要按租户、用户、灰度比例、最低版本强制升级。 | 灰度用户收到新版本,非灰度用户保持稳定版本。 | +| P1 | 资料隐私裁剪落地 | 隐私设置需要接入用户搜索、组织架构、会话资料页等读路径。 | 非授权用户无法通过其他接口绕过隐私配置。 | +| P2 | 客户端能力上报 | 后续增加设备能力、通知权限、厂商通道状态和前台活跃状态上报。 | 后台可定位推送不可达和版本兼容问题。 | + +## 11. 统一处理建议 + +1. 先做 P0:统一幂等、实时事件/Outbox、验证码真实发送与限频、P2P/好友/搜索隐私策略、第六章群业务独立表与事务状态机、文件下载签名、通话强状态机、工作台/组织权限。 +2. 再做 P1:租户策略配置、系统通知生产通道、实名审核、设备管理、资料隐私裁剪、关系审计、群禁言发送校验、群文件索引、通话质量指标、会议/日报/月报/信箱完整业务表。 +3. 最后做 P2:端到端重连测试、schema 版本演进、大群压测、批量接口体验优化、收藏导出、通话记录聚合和办公模块附件统一策略。 4. 每个增强项落地前同步更新 `IM-系统设计文档.md` 和 `IM-前后端对接文档.md` 中对应章节状态。 diff --git a/scripts/apply_hotgo_tenant_db.go b/scripts/apply_hotgo_tenant_db.go index daa0d65..b059494 100644 --- a/scripts/apply_hotgo_tenant_db.go +++ b/scripts/apply_hotgo_tenant_db.go @@ -80,6 +80,237 @@ func main() { INDEX idx_im_call_records_callee(callee_id, id DESC), INDEX idx_im_call_records_status(status, updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_workbench_apps( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + icon_file_id VARCHAR(64) NOT NULL DEFAULT '', + route VARCHAR(128) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + unread_key VARCHAR(64) NOT NULL DEFAULT '', + enabled TINYINT NOT NULL DEFAULT 1, + permission VARCHAR(64) NOT NULL DEFAULT 'enabled', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_workbench_apps_code(code), + INDEX idx_im_workbench_apps_enabled_order(enabled, sort_order) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_org_departments( + id BIGINT NOT NULL AUTO_INCREMENT, + department_id VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + parent_id VARCHAR(64) NOT NULL DEFAULT 'root', + sort_order INT NOT NULL DEFAULT 0, + version INT NOT NULL DEFAULT 1, + state VARCHAR(16) NOT NULL DEFAULT 'active', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_org_departments_department(department_id), + INDEX idx_im_org_departments_parent(parent_id, state, sort_order, id), + INDEX idx_im_org_departments_name(name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_org_members( + id BIGINT NOT NULL AUTO_INCREMENT, + department_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + display_name VARCHAR(128) NOT NULL DEFAULT '', + position VARCHAR(128) NOT NULL DEFAULT '', + avatar_file_id VARCHAR(64) NOT NULL DEFAULT '', + mobile VARCHAR(32) NOT NULL DEFAULT '', + email VARCHAR(128) NOT NULL DEFAULT '', + employment_state VARCHAR(16) NOT NULL DEFAULT 'active', + contactable TINYINT NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_org_members_department_user(department_id, user_id), + INDEX idx_im_org_members_department(department_id, employment_state, sort_order, id), + INDEX idx_im_org_members_user(user_id), + INDEX idx_im_org_members_display_name(display_name), + CONSTRAINT fk_im_org_members_department FOREIGN KEY(department_id) REFERENCES tinode.im_org_departments(department_id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_meetings( + id BIGINT NOT NULL AUTO_INCREMENT, + meeting_id VARCHAR(64) NOT NULL, + organizer_id BIGINT NOT NULL, + title VARCHAR(200) NOT NULL, + start_at DATETIME(3) NOT NULL, + end_at DATETIME(3) NOT NULL, + location VARCHAR(256) NOT NULL DEFAULT '', + state VARCHAR(16) NOT NULL DEFAULT 'scheduled', + reminder_minutes JSON, + idempotency_key VARCHAR(128), + version INT NOT NULL DEFAULT 1, + canceled_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_meetings_meeting(meeting_id), + UNIQUE INDEX uk_im_meetings_idempotency(organizer_id, idempotency_key), + INDEX idx_im_meetings_organizer(organizer_id, start_at), + INDEX idx_im_meetings_state_time(state, start_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_meeting_participants( + id BIGINT NOT NULL AUTO_INCREMENT, + meeting_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + response VARCHAR(16) NOT NULL DEFAULT 'pending', + response_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_meeting_participants_user(meeting_id, user_id), + INDEX idx_im_meeting_participants_user_time(user_id, meeting_id), + INDEX idx_im_meeting_participants_response(meeting_id, response), + CONSTRAINT fk_im_meeting_participants_meeting FOREIGN KEY(meeting_id) REFERENCES tinode.im_meetings(meeting_id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_daily_reports( + id BIGINT NOT NULL AUTO_INCREMENT, + report_id VARCHAR(64) NOT NULL, + author_id BIGINT NOT NULL, + report_date DATE NOT NULL, + content TEXT NOT NULL, + attachments JSON, + status VARCHAR(16) NOT NULL DEFAULT 'draft', + read_state VARCHAR(16) NOT NULL DEFAULT 'unread', + version INT NOT NULL DEFAULT 1, + submitted_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_daily_reports_report(report_id), + UNIQUE INDEX uk_im_daily_reports_author_date(author_id, report_date), + INDEX idx_im_daily_reports_author_id(author_id, id DESC), + INDEX idx_im_daily_reports_date_status(report_date, status, id DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_monthly_reports( + id BIGINT NOT NULL AUTO_INCREMENT, + report_id VARCHAR(64) NOT NULL, + author_id BIGINT NOT NULL, + report_month CHAR(7) NOT NULL, + work_summary TEXT NOT NULL, + next_plan TEXT NOT NULL, + attachments JSON, + status VARCHAR(16) NOT NULL DEFAULT 'draft', + read_state VARCHAR(16) NOT NULL DEFAULT 'unread', + version INT NOT NULL DEFAULT 1, + submitted_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_monthly_reports_report(report_id), + UNIQUE INDEX uk_im_monthly_reports_author_month(author_id, report_month), + INDEX idx_im_monthly_reports_author_id(author_id, id DESC), + INDEX idx_im_monthly_reports_month_status(report_month, status, id DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_mailbox_config( + id BIGINT NOT NULL AUTO_INCREMENT, + enabled TINYINT NOT NULL DEFAULT 1, + allow_anonymous TINYINT NOT NULL DEFAULT 1, + required_fields JSON, + max_length INT NOT NULL DEFAULT 5000, + attachment_policy JSON, + notice_title VARCHAR(128) NOT NULL DEFAULT '', + notice_content TEXT NOT NULL, + notice_updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_mailbox_submissions( + id BIGINT NOT NULL AUTO_INCREMENT, + submission_id VARCHAR(64) NOT NULL, + sender_id BIGINT NOT NULL, + subject VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + anonymous TINYINT NOT NULL DEFAULT 0, + attachment_file_ids JSON, + notice_version VARCHAR(64) NOT NULL DEFAULT '', + idempotency_key VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'received', + submitted_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_mailbox_submissions_submission(submission_id), + UNIQUE INDEX uk_im_mailbox_submissions_idempotency(sender_id, idempotency_key), + INDEX idx_im_mailbox_submissions_sender(sender_id, id DESC), + INDEX idx_im_mailbox_submissions_status(status, id DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_user_profiles( + user_id BIGINT NOT NULL, + avatar_file_id VARCHAR(64) NOT NULL DEFAULT '', + nickname VARCHAR(64) NOT NULL DEFAULT '', + birth_date DATE, + signature VARCHAR(256) NOT NULL DEFAULT '', + gender VARCHAR(16) NOT NULL DEFAULT '', + region VARCHAR(128) NOT NULL DEFAULT '', + public_account VARCHAR(64) NOT NULL DEFAULT '', + version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings( + user_id BIGINT NOT NULL, + add_me_methods JSON, + profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', + last_seen_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', + read_receipts TINYINT NOT NULL DEFAULT 1, + version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_app_releases( + id BIGINT NOT NULL AUTO_INCREMENT, + platform VARCHAR(32) NOT NULL, + channel VARCHAR(32) NOT NULL DEFAULT 'stable', + latest_version VARCHAR(64) NOT NULL, + min_supported_version VARCHAR(64) NOT NULL, + update_type VARCHAR(16) NOT NULL DEFAULT 'none', + release_notes TEXT NOT NULL, + store_url VARCHAR(512) NOT NULL DEFAULT '', + package_url VARCHAR(512) NOT NULL DEFAULT '', + checksum VARCHAR(128) NOT NULL DEFAULT '', + remind_after_seconds INT NOT NULL DEFAULT 0, + published_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_app_releases_platform_channel(platform, channel) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_device_push_tokens( + device_id VARCHAR(128) NOT NULL, + user_id BIGINT NOT NULL, + provider VARCHAR(32) NOT NULL, + token VARCHAR(512) NOT NULL, + app_version VARCHAR(64) NOT NULL DEFAULT '', + platform VARCHAR(32) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(device_id), + UNIQUE INDEX uk_im_device_push_tokens_provider_token(provider, token), + INDEX idx_im_device_push_tokens_user(user_id, updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings( + user_id BIGINT NOT NULL, + purpose VARCHAR(64) NOT NULL, + password_hash VARBINARY(255) NOT NULL, + enabled TINYINT NOT NULL DEFAULT 0, + policy_version INT NOT NULL DEFAULT 1, + restrictions JSON, + enabled_at DATETIME(3), + failed_attempts INT NOT NULL DEFAULT 0, + locked_until DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(user_id, purpose) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, } for _, stmt := range statements { if _, err = db.Exec(stmt); err != nil { @@ -93,6 +324,10 @@ func main() { "ALTER TABLE tinode.im_tenant ADD COLUMN tenant_desc VARCHAR(256) AFTER name", "ALTER TABLE tinode.im_tenant ADD COLUMN db_name VARCHAR(128) NOT NULL DEFAULT '' AFTER tenant_desc", "ALTER TABLE tinode.im_tenant ADD COLUMN db_dsn VARCHAR(1024) NOT NULL DEFAULT '' AFTER db_name", + "ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN enabled TINYINT NOT NULL DEFAULT 0 AFTER password_hash", + "ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN policy_version INT NOT NULL DEFAULT 1 AFTER enabled", + "ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN restrictions JSON AFTER policy_version", + "ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN enabled_at DATETIME(3) AFTER restrictions", } { if _, err = db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "Duplicate column name") { fatal(fmt.Errorf("exec failed: %s: %w", stmt, err)) @@ -108,6 +343,28 @@ func main() { ON DUPLICATE KEY UPDATE updated_at=CURRENT_TIMESTAMP(3)`); err != nil { fatal(err) } + if _, err = db.Exec(`INSERT INTO tinode.im_workbench_apps(code,name,route,sort_order,unread_key,enabled,permission) + VALUES + ('organization','组织架构','/organization',10,'organization',1,'enabled'), + ('meetings','会议通知','/meetings',20,'meetings',1,'enabled'), + ('daily_reports','日报','/reports/daily',30,'daily_reports',1,'enabled'), + ('monthly_reports','月报','/reports/monthly',40,'monthly_reports',1,'enabled'), + ('executive_mailbox','总经理信箱','/executive-mailbox',50,'executive_mailbox',1,'enabled') + ON DUPLICATE KEY UPDATE name=VALUES(name),route=VALUES(route),sort_order=VALUES(sort_order),unread_key=VALUES(unread_key),updated_at=CURRENT_TIMESTAMP(3)`); err != nil { + fatal(err) + } + if _, err = db.Exec(`INSERT INTO tinode.im_mailbox_config(id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content) + VALUES(1,1,1,'["subject","content"]',5000,'{"max_files":5,"purpose":"executive_mailbox"}','总经理信箱','请如实填写诉求,匿名提交将按企业政策保护展示身份。') + ON DUPLICATE KEY UPDATE updated_at=updated_at`); err != nil { + fatal(err) + } + if _, err = db.Exec(`INSERT INTO tinode.im_app_releases(platform,channel,latest_version,min_supported_version,update_type,release_notes,remind_after_seconds) + VALUES + ('ios','stable','1.0.0','1.0.0','none','当前已是最新版本。',0), + ('android','stable','1.0.0','1.0.0','none','当前已是最新版本。',0) + ON DUPLICATE KEY UPDATE updated_at=CURRENT_TIMESTAMP(3)`); err != nil { + fatal(err) + } if _, err = db.Exec("UPDATE tinode.im_tenant SET db_name='tinode' WHERE code='default' AND (db_name='' OR db_name IS NULL)"); err != nil { fatal(err) } diff --git a/server/db/adapter.go b/server/db/adapter.go index 992b1e3..4f4cf4c 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -292,6 +292,39 @@ type TenantBusinessAdapter interface { TenantCallCreate(t.TenantID, *t.CallRecord) error TenantCallAccept(t.TenantID, string, int, time.Time) error TenantCallFinish(t.TenantID, string, int, string, int64, time.Time) error + TenantCallList(t.TenantID, t.Uid, string, string, int) ([]t.CallRecord, string, error) + + TenantWorkbenchModules(t.TenantID, t.Uid) ([]t.WorkbenchModule, int, error) + TenantOrganizationDepartments(t.TenantID, t.Uid, string, string, int) ([]t.OrganizationDepartment, string, error) + TenantOrganizationMembers(t.TenantID, t.Uid, string, string, int) ([]t.OrganizationMember, string, error) + TenantOrganizationSearch(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationSearchItem, string, error) + TenantMeetingCreate(t.TenantID, t.Uid, *t.Meeting, []t.Uid) (*t.Meeting, error) + TenantMeetingGet(t.TenantID, t.Uid, string) (*t.Meeting, error) + TenantMeetingUpdate(t.TenantID, t.Uid, string, t.MeetingPatch, string) (*t.Meeting, error) + TenantMeetingCancel(t.TenantID, t.Uid, string, string) (*t.Meeting, error) + TenantMeetingRemind(t.TenantID, t.Uid, string, string) ([]t.MeetingParticipant, error) + TenantMeetingRespond(t.TenantID, t.Uid, string, string, int) (*t.Meeting, error) + TenantDailyReportList(t.TenantID, t.Uid, string, string, string, string, string, int) ([]t.DailyReport, string, error) + TenantDailyReportCreateDraft(t.TenantID, t.Uid, string, time.Time) (*t.DailyReport, error) + TenantDailyReportGet(t.TenantID, t.Uid, string) (*t.DailyReport, error) + TenantDailyReportSaveDraft(t.TenantID, t.Uid, string, string, []string, int) (*t.DailyReport, error) + TenantDailyReportSubmit(t.TenantID, t.Uid, string, int) (*t.DailyReport, error) + TenantDailyReportStats(t.TenantID, t.Uid, time.Time, string) (*t.DailyReportStats, error) + TenantDailyReportRemind(t.TenantID, t.Uid, time.Time, []t.Uid, string) ([]t.Uid, error) + TenantMonthlyReportList(t.TenantID, t.Uid, string, string, string, string, string, int) ([]t.MonthlyReport, string, error) + TenantMonthlyReportCreateDraft(t.TenantID, t.Uid, string, string) (*t.MonthlyReport, error) + TenantMonthlyReportGet(t.TenantID, t.Uid, string) (*t.MonthlyReport, error) + TenantMonthlyReportSaveDraft(t.TenantID, t.Uid, string, string, string, []string, int) (*t.MonthlyReport, error) + TenantMonthlyReportSubmit(t.TenantID, t.Uid, string, int) (*t.MonthlyReport, error) + TenantMonthlyReportStats(t.TenantID, t.Uid, string, string) (*t.MonthlyReportStats, error) + TenantMailboxConfigGet(t.TenantID, t.Uid) (*t.MailboxConfig, error) + TenantMailboxSubmissionCreate(t.TenantID, t.Uid, *t.MailboxSubmission, string) (*t.MailboxSubmission, error) + TenantUserProfileGet(t.TenantID, t.Uid) (*t.UserProfile, error) + TenantUserProfileUpdate(t.TenantID, t.Uid, t.UserProfilePatch) (*t.UserProfile, error) + TenantUserPrivacyGet(t.TenantID, t.Uid) (*t.UserPrivacySettings, error) + TenantUserPrivacyUpdate(t.TenantID, t.Uid, t.UserPrivacyPatch) (*t.UserPrivacySettings, error) + TenantAppReleaseLatest(t.TenantID, string, string) (*t.AppRelease, error) + TenantDevicePushTokenUpsert(t.TenantID, *t.DevicePushToken) (*t.DevicePushToken, error) TenantFavoriteCreate(t.TenantID, *t.FavoriteItem) (*t.FavoriteItem, error) TenantFavoriteList(t.TenantID, t.Uid, string, string, string, int) ([]t.FavoriteItem, string, error) @@ -330,6 +363,8 @@ type TenantBusinessAdapter interface { TenantIndependentPasswordGet(t.TenantID, t.Uid, string) (*t.IndependentPassword, error) TenantIndependentPasswordUpsert(t.TenantID, *t.IndependentPassword) error TenantIndependentPasswordUpdateAttempts(t.TenantID, t.Uid, string, int, *time.Time, time.Time) error + TenantYouthModeGet(t.TenantID, t.Uid) (*t.YouthModeSettings, error) + TenantYouthModeSet(t.TenantID, t.Uid, bool, int, time.Time) (*t.YouthModeSettings, error) TenantSystemNotificationCreate(t.TenantID, *t.SystemNotification) error TenantSystemNotificationList(t.TenantID, t.Uid, string, int) ([]t.SystemNotification, string, error) diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index a35c565..ff6609a 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -794,6 +794,24 @@ func (a *adapter) CreateDb(reset bool) error { if err = createIMCallTables(tx); err != nil { return err } + if err = createIMWorkbenchTables(tx); err != nil { + return err + } + if err = createIMOrganizationTables(tx); err != nil { + return err + } + if err = createIMMeetingTables(tx); err != nil { + return err + } + if err = createIMReportTables(tx); err != nil { + return err + } + if err = createIMMailboxTables(tx); err != nil { + return err + } + if err = createIMUserProductTables(tx); err != nil { + return err + } // Deletion log if _, err = tx.Exec( @@ -950,6 +968,299 @@ func createIMCallTables(tx *sql.Tx) error { return err } +func createIMWorkbenchTables(tx *sql.Tx) error { + if _, err := tx.Exec( + `CREATE TABLE im_workbench_apps( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + icon_file_id VARCHAR(64) NOT NULL DEFAULT '', + route VARCHAR(128) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + unread_key VARCHAR(64) NOT NULL DEFAULT '', + enabled TINYINT NOT NULL DEFAULT 1, + permission VARCHAR(64) NOT NULL DEFAULT 'enabled', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_workbench_apps_code(code), + INDEX idx_im_workbench_apps_enabled_order(enabled, sort_order) + )`); err != nil { + return err + } + _, err := tx.Exec( + `INSERT INTO im_workbench_apps(code,name,route,sort_order,unread_key,enabled,permission) + VALUES + ('organization','组织架构','/organization',10,'organization',1,'enabled'), + ('meetings','会议通知','/meetings',20,'meetings',1,'enabled'), + ('daily_reports','日报','/reports/daily',30,'daily_reports',1,'enabled'), + ('monthly_reports','月报','/reports/monthly',40,'monthly_reports',1,'enabled'), + ('executive_mailbox','总经理信箱','/executive-mailbox',50,'executive_mailbox',1,'enabled') + ON DUPLICATE KEY UPDATE + name=VALUES(name), + route=VALUES(route), + sort_order=VALUES(sort_order), + unread_key=VALUES(unread_key), + updated_at=CURRENT_TIMESTAMP(3)`) + return err +} + +func createIMOrganizationTables(tx *sql.Tx) error { + statements := []string{ + `CREATE TABLE im_org_departments( + id BIGINT NOT NULL AUTO_INCREMENT, + department_id VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + parent_id VARCHAR(64) NOT NULL DEFAULT 'root', + sort_order INT NOT NULL DEFAULT 0, + version INT NOT NULL DEFAULT 1, + state VARCHAR(16) NOT NULL DEFAULT 'active', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_org_departments_department(department_id), + INDEX idx_im_org_departments_parent(parent_id, state, sort_order, id), + INDEX idx_im_org_departments_name(name) + )`, + `CREATE TABLE im_org_members( + id BIGINT NOT NULL AUTO_INCREMENT, + department_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + display_name VARCHAR(128) NOT NULL DEFAULT '', + position VARCHAR(128) NOT NULL DEFAULT '', + avatar_file_id VARCHAR(64) NOT NULL DEFAULT '', + mobile VARCHAR(32) NOT NULL DEFAULT '', + email VARCHAR(128) NOT NULL DEFAULT '', + employment_state VARCHAR(16) NOT NULL DEFAULT 'active', + contactable TINYINT NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_org_members_department_user(department_id, user_id), + INDEX idx_im_org_members_department(department_id, employment_state, sort_order, id), + INDEX idx_im_org_members_user(user_id), + INDEX idx_im_org_members_display_name(display_name), + CONSTRAINT fk_im_org_members_department FOREIGN KEY(department_id) REFERENCES im_org_departments(department_id) ON DELETE CASCADE + )`, + } + for _, stmt := range statements { + if _, err := tx.Exec(stmt); err != nil { + return err + } + } + return nil +} + +func createIMMeetingTables(tx *sql.Tx) error { + statements := []string{ + `CREATE TABLE im_meetings( + id BIGINT NOT NULL AUTO_INCREMENT, + meeting_id VARCHAR(64) NOT NULL, + organizer_id BIGINT NOT NULL, + title VARCHAR(200) NOT NULL, + start_at DATETIME(3) NOT NULL, + end_at DATETIME(3) NOT NULL, + location VARCHAR(256) NOT NULL DEFAULT '', + state VARCHAR(16) NOT NULL DEFAULT 'scheduled', + reminder_minutes JSON, + idempotency_key VARCHAR(128), + version INT NOT NULL DEFAULT 1, + canceled_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_meetings_meeting(meeting_id), + UNIQUE INDEX uk_im_meetings_idempotency(organizer_id, idempotency_key), + INDEX idx_im_meetings_organizer(organizer_id, start_at), + INDEX idx_im_meetings_state_time(state, start_at) + )`, + `CREATE TABLE im_meeting_participants( + id BIGINT NOT NULL AUTO_INCREMENT, + meeting_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + response VARCHAR(16) NOT NULL DEFAULT 'pending', + response_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_meeting_participants_user(meeting_id, user_id), + INDEX idx_im_meeting_participants_user_time(user_id, meeting_id), + INDEX idx_im_meeting_participants_response(meeting_id, response), + CONSTRAINT fk_im_meeting_participants_meeting FOREIGN KEY(meeting_id) REFERENCES im_meetings(meeting_id) ON DELETE CASCADE + )`, + } + for _, stmt := range statements { + if _, err := tx.Exec(stmt); err != nil { + return err + } + } + return nil +} + +func createIMReportTables(tx *sql.Tx) error { + if _, err := tx.Exec( + `CREATE TABLE im_daily_reports( + id BIGINT NOT NULL AUTO_INCREMENT, + report_id VARCHAR(64) NOT NULL, + author_id BIGINT NOT NULL, + report_date DATE NOT NULL, + content TEXT NOT NULL, + attachments JSON, + status VARCHAR(16) NOT NULL DEFAULT 'draft', + read_state VARCHAR(16) NOT NULL DEFAULT 'unread', + version INT NOT NULL DEFAULT 1, + submitted_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_daily_reports_report(report_id), + UNIQUE INDEX uk_im_daily_reports_author_date(author_id, report_date), + INDEX idx_im_daily_reports_author_id(author_id, id DESC), + INDEX idx_im_daily_reports_date_status(report_date, status, id DESC) + )`); err != nil { + return err + } + _, err := tx.Exec( + `CREATE TABLE im_monthly_reports( + id BIGINT NOT NULL AUTO_INCREMENT, + report_id VARCHAR(64) NOT NULL, + author_id BIGINT NOT NULL, + report_month CHAR(7) NOT NULL, + work_summary TEXT NOT NULL, + next_plan TEXT NOT NULL, + attachments JSON, + status VARCHAR(16) NOT NULL DEFAULT 'draft', + read_state VARCHAR(16) NOT NULL DEFAULT 'unread', + version INT NOT NULL DEFAULT 1, + submitted_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_monthly_reports_report(report_id), + UNIQUE INDEX uk_im_monthly_reports_author_month(author_id, report_month), + INDEX idx_im_monthly_reports_author_id(author_id, id DESC), + INDEX idx_im_monthly_reports_month_status(report_month, status, id DESC) + )`) + return err +} + +func createIMMailboxTables(tx *sql.Tx) error { + if _, err := tx.Exec( + `CREATE TABLE im_mailbox_config( + id BIGINT NOT NULL AUTO_INCREMENT, + enabled TINYINT NOT NULL DEFAULT 1, + allow_anonymous TINYINT NOT NULL DEFAULT 1, + required_fields JSON, + max_length INT NOT NULL DEFAULT 5000, + attachment_policy JSON, + notice_title VARCHAR(128) NOT NULL DEFAULT '', + notice_content TEXT NOT NULL, + notice_updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id) + )`); err != nil { + return err + } + if _, err := tx.Exec( + `INSERT INTO im_mailbox_config(id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content) + VALUES(1,1,1,'["subject","content"]',5000,'{"max_files":5,"purpose":"executive_mailbox"}','总经理信箱','请如实填写诉求,匿名提交将按企业政策保护展示身份。') + ON DUPLICATE KEY UPDATE updated_at=updated_at`); err != nil { + return err + } + _, err := tx.Exec( + `CREATE TABLE im_mailbox_submissions( + id BIGINT NOT NULL AUTO_INCREMENT, + submission_id VARCHAR(64) NOT NULL, + sender_id BIGINT NOT NULL, + subject VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + anonymous TINYINT NOT NULL DEFAULT 0, + attachment_file_ids JSON, + notice_version VARCHAR(64) NOT NULL DEFAULT '', + idempotency_key VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'received', + submitted_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_mailbox_submissions_submission(submission_id), + UNIQUE INDEX uk_im_mailbox_submissions_idempotency(sender_id, idempotency_key), + INDEX idx_im_mailbox_submissions_sender(sender_id, id DESC), + INDEX idx_im_mailbox_submissions_status(status, id DESC) + )`) + return err +} + +func createIMUserProductTables(tx *sql.Tx) error { + statements := []string{ + `CREATE TABLE im_user_profiles( + user_id BIGINT NOT NULL, + avatar_file_id VARCHAR(64) NOT NULL DEFAULT '', + nickname VARCHAR(64) NOT NULL DEFAULT '', + birth_date DATE, + signature VARCHAR(256) NOT NULL DEFAULT '', + gender VARCHAR(16) NOT NULL DEFAULT '', + region VARCHAR(128) NOT NULL DEFAULT '', + public_account VARCHAR(64) NOT NULL DEFAULT '', + version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(user_id) + )`, + `CREATE TABLE im_user_privacy_settings( + user_id BIGINT NOT NULL, + add_me_methods JSON, + profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', + last_seen_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', + read_receipts TINYINT NOT NULL DEFAULT 1, + version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(user_id) + )`, + `CREATE TABLE im_app_releases( + id BIGINT NOT NULL AUTO_INCREMENT, + platform VARCHAR(32) NOT NULL, + channel VARCHAR(32) NOT NULL DEFAULT 'stable', + latest_version VARCHAR(64) NOT NULL, + min_supported_version VARCHAR(64) NOT NULL, + update_type VARCHAR(16) NOT NULL DEFAULT 'none', + release_notes TEXT NOT NULL, + store_url VARCHAR(512) NOT NULL DEFAULT '', + package_url VARCHAR(512) NOT NULL DEFAULT '', + checksum VARCHAR(128) NOT NULL DEFAULT '', + remind_after_seconds INT NOT NULL DEFAULT 0, + published_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_app_releases_platform_channel(platform, channel) + )`, + `CREATE TABLE im_device_push_tokens( + device_id VARCHAR(128) NOT NULL, + user_id BIGINT NOT NULL, + provider VARCHAR(32) NOT NULL, + token VARCHAR(512) NOT NULL, + app_version VARCHAR(64) NOT NULL DEFAULT '', + platform VARCHAR(32) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(device_id), + UNIQUE INDEX uk_im_device_push_tokens_provider_token(provider, token), + INDEX idx_im_device_push_tokens_user(user_id, updated_at) + )`, + } + for _, stmt := range statements { + if _, err := tx.Exec(stmt); err != nil { + return err + } + } + return nil +} + func ensureTenantRoutingColumns(a *adapter) error { statements := []string{ "ALTER TABLE im_tenant ADD COLUMN db_name VARCHAR(128) NOT NULL DEFAULT '' AFTER tenant_desc", @@ -1117,6 +1428,10 @@ func createIMSecurityTables(tx *sql.Tx) error { user_id BIGINT NOT NULL, purpose VARCHAR(64) NOT NULL, password_hash VARBINARY(255) NOT NULL, + enabled TINYINT NOT NULL DEFAULT 0, + policy_version INT NOT NULL DEFAULT 1, + restrictions JSON, + enabled_at DATETIME(3), failed_attempts INT NOT NULL DEFAULT 0, locked_until DATETIME(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 0e99c8e..2553a5c 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -7,6 +7,7 @@ import ( "context" "crypto/sha256" "database/sql" + "encoding/json" "fmt" "sort" "strconv" @@ -2054,6 +2055,1913 @@ func (a *adapter) TenantCallFinish(id t.TenantID, callID string, seq int, status return err } +func (a *adapter) TenantCallList(id t.TenantID, uid t.Uid, topic, cursor string, limit int) ([]t.CallRecord, string, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, "", err + } + if tenantAdp != a { + return tenantAdp.TenantCallList(id, uid, topic, cursor, limit) + } + } + if err := validTenant(id); err != nil || uid.IsZero() { + return nil, "", t.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + where := []string{"(caller_id=? OR callee_id=?)"} + args := []any{decodeUID(uid), decodeUID(uid)} + if topic != "" { + where = append(where, "topic=?") + args = append(args, topic) + } + if cursor != "" { + parsed, err := strconv.ParseInt(cursor, 10, 64) + if err != nil || parsed <= 0 { + return nil, "", t.ErrMalformed + } + where = append(where, "id layoutVersion { + layoutVersion = int(module.UpdatedAt.Unix()) + } + modules = append(modules, module) + } + if err = rows.Err(); err != nil { + return nil, 0, err + } + if layoutVersion == 0 { + layoutVersion = 1 + } + return modules, layoutVersion, nil +} + +func (a *adapter) TenantOrganizationDepartments(id t.TenantID, uid t.Uid, parentID, cursor string, limit int) ([]t.OrganizationDepartment, string, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, "", err + } + if tenantAdp != a { + return tenantAdp.TenantOrganizationDepartments(id, uid, parentID, cursor, limit) + } + } + if err := validTenant(id); err != nil || uid.IsZero() { + return nil, "", t.ErrMalformed + } + parentID = strings.TrimSpace(parentID) + if parentID == "" { + parentID = "root" + } + if limit <= 0 || limit > 100 { + limit = 50 + } + where := []string{"d.parent_id=?", "d.state='active'"} + args := []any{parentID} + if cursor != "" { + parsed, err := strconv.ParseInt(cursor, 10, 64) + if err != nil || parsed <= 0 { + return nil, "", t.ErrMalformed + } + where = append(where, "d.id>?") + args = append(args, parsed) + } + args = append(args, limit+1) + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + rows, err := a.db.QueryxContext(ctx, `SELECT d.id,d.department_id,d.name,d.parent_id,d.sort_order,d.version,d.state,d.created_at,d.updated_at, + (SELECT COUNT(*) FROM im_org_departments AS child WHERE child.parent_id=d.department_id AND child.state='active') AS child_count, + (SELECT COUNT(*) FROM im_org_members AS m WHERE m.department_id=d.department_id) AS member_count + FROM im_org_departments AS d + WHERE `+strings.Join(where, " AND ")+` + ORDER BY d.sort_order ASC,d.id ASC LIMIT ?`, args...) + if err != nil { + return nil, "", err + } + defer rows.Close() + departments := make([]t.OrganizationDepartment, 0, limit) + nextCursor := "" + for rows.Next() { + var department t.OrganizationDepartment + if err = rows.StructScan(&department); err != nil { + return nil, "", err + } + if len(departments) == limit { + nextCursor = strconv.FormatInt(department.ID, 10) + continue + } + departments = append(departments, department) + } + if err = rows.Err(); err != nil { + return nil, "", err + } + return departments, nextCursor, nil +} + +func (a *adapter) TenantOrganizationMembers(id t.TenantID, uid t.Uid, departmentID, cursor string, limit int) ([]t.OrganizationMember, string, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, "", err + } + if tenantAdp != a { + return tenantAdp.TenantOrganizationMembers(id, uid, departmentID, cursor, limit) + } + } + if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(departmentID) == "" { + return nil, "", t.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + where := []string{"m.department_id=?"} + args := []any{strings.TrimSpace(departmentID)} + if cursor != "" { + parsed, err := strconv.ParseInt(cursor, 10, 64) + if err != nil || parsed <= 0 { + return nil, "", t.ErrMalformed + } + where = append(where, "m.id>?") + args = append(args, parsed) + } + args = append(args, limit+1) + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + rows, err := a.db.QueryxContext(ctx, `SELECT m.id,m.department_id,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email, + m.employment_state,m.contactable,m.sort_order,m.version,m.created_at,m.updated_at + FROM im_org_members AS m + WHERE `+strings.Join(where, " AND ")+` + ORDER BY m.sort_order ASC,m.id ASC LIMIT ?`, args...) + if err != nil { + return nil, "", err + } + defer rows.Close() + members := make([]t.OrganizationMember, 0, limit) + nextCursor := "" + for rows.Next() { + member, err := scanOrganizationMember(rows) + if err != nil { + return nil, "", err + } + if len(members) == limit { + nextCursor = strconv.FormatInt(member.ID, 10) + continue + } + members = append(members, *member) + } + if err = rows.Err(); err != nil { + return nil, "", err + } + return members, nextCursor, nil +} + +func (a *adapter) TenantOrganizationSearch(id t.TenantID, uid t.Uid, queryText, resultTypes, cursor string, limit int) ([]t.OrganizationSearchItem, string, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, "", err + } + if tenantAdp != a { + return tenantAdp.TenantOrganizationSearch(id, uid, queryText, resultTypes, cursor, limit) + } + } + if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(queryText) == "" { + return nil, "", t.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + offset := 0 + if cursor != "" { + parsed, err := strconv.Atoi(cursor) + if err != nil || parsed < 0 { + return nil, "", t.ErrMalformed + } + offset = parsed + } + includeDepartments, includeUsers := organizationSearchIncludes(resultTypes) + if !includeDepartments && !includeUsers { + return nil, "", t.ErrMalformed + } + like := "%" + strings.TrimSpace(queryText) + "%" + parts := make([]string, 0, 2) + args := make([]any, 0, 6) + if includeDepartments { + parts = append(parts, `SELECT 'department' AS result_type,d.id,d.department_id,d.name,d.parent_id,d.sort_order,d.version,d.state,d.created_at,d.updated_at, + (SELECT COUNT(*) FROM im_org_departments AS child WHERE child.parent_id=d.department_id AND child.state='active') AS child_count, + (SELECT COUNT(*) FROM im_org_members AS m WHERE m.department_id=d.department_id) AS member_count, + 0 AS user_id,'' AS display_name,'' AS position,'' AS avatar_file_id,'' AS mobile,'' AS email,'' AS employment_state,0 AS contactable + FROM im_org_departments AS d WHERE d.state='active' AND d.name LIKE ?`) + args = append(args, like) + } + if includeUsers { + parts = append(parts, `SELECT 'user' AS result_type,m.id,'' AS department_id,'' AS name,m.department_id AS parent_id,m.sort_order,m.version,'' AS state,m.created_at,m.updated_at, + 0 AS child_count,0 AS member_count,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email,m.employment_state,m.contactable + FROM im_org_members AS m + WHERE m.display_name LIKE ? OR m.mobile LIKE ? OR m.email LIKE ? OR CAST(m.user_id AS CHAR) LIKE ?`) + args = append(args, like, like, like, like) + } + args = append(args, offset, limit+1) + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + rows, err := a.db.QueryxContext(ctx, `SELECT * FROM (`+strings.Join(parts, " UNION ALL ")+`) AS results + ORDER BY result_type ASC,id ASC LIMIT ?,?`, args...) + if err != nil { + return nil, "", err + } + defer rows.Close() + items := make([]t.OrganizationSearchItem, 0, limit) + nextCursor := "" + for rows.Next() { + item, err := scanOrganizationSearchItem(rows) + if err != nil { + return nil, "", err + } + if len(items) == limit { + nextCursor = strconv.Itoa(offset + limit) + continue + } + items = append(items, *item) + } + if err = rows.Err(); err != nil { + return nil, "", err + } + return items, nextCursor, nil +} + +func organizationSearchIncludes(resultTypes string) (bool, bool) { + if strings.TrimSpace(resultTypes) == "" { + return true, true + } + includeDepartments, includeUsers := false, false + for _, item := range strings.Split(resultTypes, ",") { + switch strings.TrimSpace(item) { + case "department": + includeDepartments = true + case "user": + includeUsers = true + } + } + return includeDepartments, includeUsers +} + +func scanOrganizationMember(rows *sqlx.Rows) (*t.OrganizationMember, error) { + row := struct { + ID int64 + DepartmentID string `db:"department_id"` + UserID int64 `db:"user_id"` + DisplayName string `db:"display_name"` + Position string + AvatarFileID string `db:"avatar_file_id"` + Mobile string + Email string + EmploymentState string `db:"employment_state"` + Contactable bool + SortOrder int `db:"sort_order"` + Version int + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + }{} + if err := rows.StructScan(&row); err != nil { + return nil, err + } + return &t.OrganizationMember{ + ID: row.ID, + DepartmentID: row.DepartmentID, + UserID: store.EncodeUid(row.UserID), + DisplayName: row.DisplayName, + Position: row.Position, + AvatarFileID: row.AvatarFileID, + Mobile: row.Mobile, + Email: row.Email, + Status: row.EmploymentState, + Contactable: row.Contactable && row.EmploymentState == "active", + SortOrder: row.SortOrder, + Version: row.Version, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + }, nil +} + +func scanOrganizationSearchItem(rows *sqlx.Rows) (*t.OrganizationSearchItem, error) { + row := struct { + ResultType string `db:"result_type"` + ID int64 + DepartmentID string `db:"department_id"` + Name string + ParentID string `db:"parent_id"` + SortOrder int `db:"sort_order"` + Version int + State string + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + ChildCount int `db:"child_count"` + MemberCount int `db:"member_count"` + UserID int64 `db:"user_id"` + DisplayName string `db:"display_name"` + Position string + AvatarFileID string `db:"avatar_file_id"` + Mobile string + Email string + EmploymentState string `db:"employment_state"` + Contactable bool + }{} + if err := rows.StructScan(&row); err != nil { + return nil, err + } + item := &t.OrganizationSearchItem{Type: row.ResultType} + if row.ResultType == "department" { + item.Department = &t.OrganizationDepartment{ + ID: row.ID, + DepartmentID: row.DepartmentID, + Name: row.Name, + ParentID: row.ParentID, + SortOrder: row.SortOrder, + ChildCount: row.ChildCount, + MemberCount: row.MemberCount, + Version: row.Version, + State: row.State, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + return item, nil + } + item.Member = &t.OrganizationMember{ + ID: row.ID, + DepartmentID: row.ParentID, + UserID: store.EncodeUid(row.UserID), + DisplayName: row.DisplayName, + Position: row.Position, + AvatarFileID: row.AvatarFileID, + Mobile: row.Mobile, + Email: row.Email, + Status: row.EmploymentState, + Contactable: row.Contactable && row.EmploymentState == "active", + SortOrder: row.SortOrder, + Version: row.Version, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + return item, nil +} + +func (a *adapter) TenantMeetingCreate(id t.TenantID, uid t.Uid, meeting *t.Meeting, attendeeIDs []t.Uid) (*t.Meeting, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantMeetingCreate(id, uid, meeting, attendeeIDs) + } + } + if err := validTenant(id); err != nil || uid.IsZero() || meeting == nil || strings.TrimSpace(meeting.MeetingID) == "" { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + if meeting.IdempotencyKey != "" { + existing, err := a.meetingByIdempotency(ctx, uid, meeting.IdempotencyKey) + if err == nil { + return existing, nil + } + if err != t.ErrNotFound { + return nil, err + } + } + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { + if tx != nil { + tx.Rollback() + } + }() + reminderJSON, err := json.Marshal(meeting.ReminderMinutes) + if err != nil { + return nil, err + } + var idempotencyKey any + if meeting.IdempotencyKey != "" { + idempotencyKey = meeting.IdempotencyKey + } + _, err = tx.ExecContext(ctx, `INSERT INTO im_meetings(meeting_id,organizer_id,title,start_at,end_at,location,state,reminder_minutes,idempotency_key,version) + VALUES(?,?,?,?,?,?,?,?,?,1)`, meeting.MeetingID, decodeUID(uid), strings.TrimSpace(meeting.Title), meeting.StartAt.UTC(), meeting.EndAt.UTC(), strings.TrimSpace(meeting.Location), + t.MeetingStateScheduled, string(reminderJSON), idempotencyKey) + if err != nil { + if isDupe(err) && meeting.IdempotencyKey != "" { + tx.Rollback() + tx = nil + return a.meetingByIdempotency(ctx, uid, meeting.IdempotencyKey) + } + return nil, err + } + participants := uniqueMeetingParticipants(uid, attendeeIDs) + now := time.Now().UTC().Round(time.Millisecond) + for _, participant := range participants { + response := t.MeetingResponsePending + var responseAt any + if participant == uid { + response = t.MeetingResponseAccepted + responseAt = now + } + if _, err = tx.ExecContext(ctx, `INSERT INTO im_meeting_participants(meeting_id,user_id,response,response_at) + VALUES(?,?,?,?)`, meeting.MeetingID, decodeUID(participant), response, responseAt); err != nil { + return nil, err + } + } + if err = tx.Commit(); err != nil { + return nil, err + } + tx = nil + return a.TenantMeetingGet(id, uid, meeting.MeetingID) +} + +func (a *adapter) TenantMeetingGet(id t.TenantID, uid t.Uid, meetingID string) (*t.Meeting, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantMeetingGet(id, uid, meetingID) + } + } + if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid) + if err != nil { + return nil, err + } + participants, err := a.meetingParticipants(ctx, meeting.MeetingID) + if err != nil { + return nil, err + } + meeting.Participants = participants + return meeting, nil +} + +func (a *adapter) TenantMeetingUpdate(id t.TenantID, uid t.Uid, meetingID string, patch t.MeetingPatch, _ string) (*t.Meeting, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantMeetingUpdate(id, uid, meetingID, patch, "") + } + } + if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" || patch.Version <= 0 { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid) + if err != nil { + return nil, err + } + if meeting.OrganizerID != uid { + return nil, t.ErrPermissionDenied + } + if meeting.State != t.MeetingStateScheduled { + return nil, t.ErrPermissionDenied + } + if meeting.Version != patch.Version { + return nil, t.ErrInvalidResponse + } + effectiveStart := meeting.StartAt + effectiveEnd := meeting.EndAt + if patch.StartAt != nil { + effectiveStart = *patch.StartAt + } + if patch.EndAt != nil { + effectiveEnd = *patch.EndAt + } + if !effectiveEnd.After(effectiveStart) { + return nil, t.ErrMalformed + } + sets := []string{"version=version+1", "updated_at=CURRENT_TIMESTAMP(3)"} + args := make([]any, 0, 8) + if patch.Title != nil { + sets = append(sets, "title=?") + args = append(args, strings.TrimSpace(*patch.Title)) + } + if patch.StartAt != nil { + sets = append(sets, "start_at=?") + args = append(args, patch.StartAt.UTC()) + } + if patch.EndAt != nil { + sets = append(sets, "end_at=?") + args = append(args, patch.EndAt.UTC()) + } + if patch.Location != nil { + sets = append(sets, "location=?") + args = append(args, strings.TrimSpace(*patch.Location)) + } + if patch.ReminderMinutes != nil { + encoded, err := json.Marshal(patch.ReminderMinutes) + if err != nil { + return nil, err + } + sets = append(sets, "reminder_minutes=?") + args = append(args, string(encoded)) + } + args = append(args, meeting.MeetingID, decodeUID(uid), patch.Version) + res, err := a.db.ExecContext(ctx, `UPDATE im_meetings SET `+strings.Join(sets, ",")+` WHERE meeting_id=? AND organizer_id=? AND version=?`, args...) + if err != nil { + return nil, err + } + affected, err := res.RowsAffected() + if err != nil { + return nil, err + } + if affected == 0 { + return nil, t.ErrInvalidResponse + } + return a.TenantMeetingGet(id, uid, meeting.MeetingID) +} + +func (a *adapter) TenantMeetingCancel(id t.TenantID, uid t.Uid, meetingID, _ string) (*t.Meeting, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantMeetingCancel(id, uid, meetingID, "") + } + } + if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid) + if err != nil { + return nil, err + } + if meeting.OrganizerID != uid { + return nil, t.ErrPermissionDenied + } + if meeting.State == t.MeetingStateCanceled { + return a.TenantMeetingGet(id, uid, meeting.MeetingID) + } + _, err = a.db.ExecContext(ctx, `UPDATE im_meetings SET state=?,version=version+1,canceled_at=?,updated_at=CURRENT_TIMESTAMP(3) + WHERE meeting_id=? AND organizer_id=?`, t.MeetingStateCanceled, time.Now().UTC().Round(time.Millisecond), meeting.MeetingID, decodeUID(uid)) + if err != nil { + return nil, err + } + return a.TenantMeetingGet(id, uid, meeting.MeetingID) +} + +func (a *adapter) TenantMeetingRemind(id t.TenantID, uid t.Uid, meetingID, _ string) ([]t.MeetingParticipant, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantMeetingRemind(id, uid, meetingID, "") + } + } + if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid) + if err != nil { + return nil, err + } + if meeting.OrganizerID != uid { + return nil, t.ErrPermissionDenied + } + participants, err := a.meetingParticipantsByResponse(ctx, meeting.MeetingID, t.MeetingResponsePending) + if err != nil { + return nil, err + } + return participants, nil +} + +func (a *adapter) TenantMeetingRespond(id t.TenantID, uid t.Uid, meetingID, response string, version int) (*t.Meeting, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantMeetingRespond(id, uid, meetingID, response, version) + } + } + if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid) + if err != nil { + return nil, err + } + if meeting.State != t.MeetingStateScheduled || time.Now().UTC().After(meeting.EndAt) { + return nil, t.ErrPermissionDenied + } + if meeting.Version != version { + return nil, t.ErrInvalidResponse + } + res, err := a.db.ExecContext(ctx, `UPDATE im_meeting_participants SET response=?,response_at=?,updated_at=CURRENT_TIMESTAMP(3) + WHERE meeting_id=? AND user_id=?`, response, time.Now().UTC().Round(time.Millisecond), meeting.MeetingID, decodeUID(uid)) + if err != nil { + return nil, err + } + affected, err := res.RowsAffected() + if err != nil { + return nil, err + } + if affected == 0 { + return nil, t.ErrPermissionDenied + } + if _, err = a.db.ExecContext(ctx, `UPDATE im_meetings SET version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE meeting_id=? AND version=?`, meeting.MeetingID, version); err != nil { + return nil, err + } + return a.TenantMeetingGet(id, uid, meeting.MeetingID) +} + +func (a *adapter) meetingByIdempotency(ctx context.Context, uid t.Uid, idempotencyKey string) (*t.Meeting, error) { + row := struct { + MeetingID string `db:"meeting_id"` + }{} + if err := sqlx.GetContext(ctx, a.db, &row, `SELECT meeting_id FROM im_meetings WHERE organizer_id=? AND idempotency_key=?`, decodeUID(uid), idempotencyKey); err != nil { + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + return nil, err + } + return a.meetingByID(ctx, row.MeetingID, uid) +} + +func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Uid) (*t.Meeting, error) { + row := struct { + ID int64 + MeetingID string `db:"meeting_id"` + OrganizerID int64 `db:"organizer_id"` + Title string + StartAt time.Time `db:"start_at"` + EndAt time.Time `db:"end_at"` + Location string + State string + ReminderMinutes []byte `db:"reminder_minutes"` + IdempotencyKey sql.NullString `db:"idempotency_key"` + Version int + CanceledAt *time.Time `db:"canceled_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + }{} + if err := sqlx.GetContext(ctx, a.db, &row, `SELECT m.* FROM im_meetings AS m + WHERE m.meeting_id=? AND (m.organizer_id=? OR EXISTS ( + SELECT 1 FROM im_meeting_participants AS p WHERE p.meeting_id=m.meeting_id AND p.user_id=? + ))`, meetingID, decodeUID(viewer), decodeUID(viewer)); err != nil { + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + return nil, err + } + meeting := &t.Meeting{ + ID: row.ID, + MeetingID: row.MeetingID, + OrganizerID: store.EncodeUid(row.OrganizerID), + Title: row.Title, + StartAt: row.StartAt, + EndAt: row.EndAt, + Location: row.Location, + State: row.State, + Version: row.Version, + CanceledAt: row.CanceledAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + if row.IdempotencyKey.Valid { + meeting.IdempotencyKey = row.IdempotencyKey.String + } + if len(row.ReminderMinutes) > 0 { + _ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes) + } + return meeting, nil +} + +func (a *adapter) meetingParticipants(ctx context.Context, meetingID string) ([]t.MeetingParticipant, error) { + return a.meetingParticipantsByQuery(ctx, `SELECT * FROM im_meeting_participants WHERE meeting_id=? ORDER BY id ASC`, meetingID) +} + +func (a *adapter) meetingParticipantsByResponse(ctx context.Context, meetingID, response string) ([]t.MeetingParticipant, error) { + return a.meetingParticipantsByQuery(ctx, `SELECT * FROM im_meeting_participants WHERE meeting_id=? AND response=? ORDER BY id ASC`, meetingID, response) +} + +func (a *adapter) meetingParticipantsByQuery(ctx context.Context, query string, args ...any) ([]t.MeetingParticipant, error) { + rows, err := a.db.QueryxContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + participants := make([]t.MeetingParticipant, 0, 8) + for rows.Next() { + row := struct { + ID int64 + MeetingID string `db:"meeting_id"` + UserID int64 `db:"user_id"` + Response string + ResponseAt *time.Time `db:"response_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + }{} + if err = rows.StructScan(&row); err != nil { + return nil, err + } + participants = append(participants, t.MeetingParticipant{ + ID: row.ID, + MeetingID: row.MeetingID, + UserID: store.EncodeUid(row.UserID), + Response: row.Response, + ResponseAt: row.ResponseAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + }) + } + if err = rows.Err(); err != nil { + return nil, err + } + return participants, nil +} + +func uniqueMeetingParticipants(organizer t.Uid, attendeeIDs []t.Uid) []t.Uid { + seen := map[t.Uid]bool{organizer: true} + participants := []t.Uid{organizer} + for _, uid := range attendeeIDs { + if uid.IsZero() || seen[uid] { + continue + } + seen[uid] = true + participants = append(participants, uid) + } + return participants +} + +func (a *adapter) TenantDailyReportList(id t.TenantID, uid t.Uid, scope, from, to, status, cursor string, limit int) ([]t.DailyReport, string, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, "", err + } + if tenantAdp != a { + return tenantAdp.TenantDailyReportList(id, uid, scope, from, to, status, cursor, limit) + } + } + if err := validTenant(id); err != nil || uid.IsZero() { + return nil, "", t.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + where := []string{"1=1"} + args := []any{} + if scope == "" || scope == "mine" { + where = append(where, "author_id=?") + args = append(args, decodeUID(uid)) + } else if scope != "managed" { + return nil, "", t.ErrMalformed + } + if from != "" { + where = append(where, "report_date>=?") + args = append(args, from) + } + if to != "" { + where = append(where, "report_date<=?") + args = append(args, to) + } + if status != "" { + where = append(where, "status=?") + args = append(args, status) + } + if cursor != "" { + parsed, err := strconv.ParseInt(cursor, 10, 64) + if err != nil || parsed <= 0 { + return nil, "", t.ErrMalformed + } + where = append(where, "id 0 { + _ = json.Unmarshal(row.Attachments, &report.Attachments) + } + return report, nil +} + +func (a *adapter) TenantMonthlyReportList(id t.TenantID, uid t.Uid, scope, fromMonth, toMonth, status, cursor string, limit int) ([]t.MonthlyReport, string, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, "", err + } + if tenantAdp != a { + return tenantAdp.TenantMonthlyReportList(id, uid, scope, fromMonth, toMonth, status, cursor, limit) + } + } + if err := validTenant(id); err != nil || uid.IsZero() { + return nil, "", t.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + where := []string{"1=1"} + args := []any{} + if scope == "" || scope == "mine" { + where = append(where, "author_id=?") + args = append(args, decodeUID(uid)) + } else if scope != "managed" { + return nil, "", t.ErrMalformed + } + if fromMonth != "" { + where = append(where, "report_month>=?") + args = append(args, fromMonth) + } + if toMonth != "" { + where = append(where, "report_month<=?") + args = append(args, toMonth) + } + if status != "" { + where = append(where, "status=?") + args = append(args, status) + } + if cursor != "" { + parsed, err := strconv.ParseInt(cursor, 10, 64) + if err != nil || parsed <= 0 { + return nil, "", t.ErrMalformed + } + where = append(where, "id 0 { + _ = json.Unmarshal(row.Attachments, &report.Attachments) + } + return report, nil +} + +func (a *adapter) TenantMailboxConfigGet(id t.TenantID, uid t.Uid) (*t.MailboxConfig, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantMailboxConfigGet(id, uid) + } + } + if err := validTenant(id); err != nil || uid.IsZero() { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + return a.mailboxConfig(ctx) +} + +func (a *adapter) TenantMailboxSubmissionCreate(id t.TenantID, uid t.Uid, submission *t.MailboxSubmission, idempotencyKey string) (*t.MailboxSubmission, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantMailboxSubmissionCreate(id, uid, submission, idempotencyKey) + } + } + if err := validTenant(id); err != nil || uid.IsZero() || submission == nil { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + existing, err := a.mailboxSubmissionByIdempotency(ctx, uid, idempotencyKey) + if err == nil { + return existing, nil + } + if err != t.ErrNotFound { + return nil, err + } + config, err := a.mailboxConfig(ctx) + if err != nil { + return nil, err + } + if !config.Enabled || (submission.Anonymous && !config.AllowAnonymous) || len([]rune(submission.Content)) > config.MaxLength { + return nil, t.ErrPolicy + } + currentNoticeVersion := config.Notice.UpdatedAt.UTC().Format(time.RFC3339Nano) + if submission.NoticeVersion != "" && submission.NoticeVersion != currentNoticeVersion { + return nil, t.ErrInvalidResponse + } + attachments, err := json.Marshal(submission.AttachmentFileIDs) + if err != nil { + return nil, err + } + now := time.Now().UTC().Round(time.Millisecond) + _, err = a.db.ExecContext(ctx, `INSERT INTO im_mailbox_submissions(submission_id,sender_id,subject,content,anonymous,attachment_file_ids,notice_version,idempotency_key,status,submitted_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`, submission.SubmissionID, decodeUID(uid), strings.TrimSpace(submission.Subject), strings.TrimSpace(submission.Content), submission.Anonymous, + string(attachments), currentNoticeVersion, idempotencyKey, t.MailboxSubmissionStatusReceived, now) + if err != nil { + if isDupe(err) { + return a.mailboxSubmissionByIdempotency(ctx, uid, idempotencyKey) + } + return nil, err + } + return a.mailboxSubmissionById(ctx, submission.SubmissionID) +} + +func (a *adapter) mailboxConfig(ctx context.Context) (*t.MailboxConfig, error) { + row := struct { + Enabled bool + AllowAnonymous bool `db:"allow_anonymous"` + RequiredFields []byte `db:"required_fields"` + MaxLength int `db:"max_length"` + AttachmentPolicy []byte `db:"attachment_policy"` + NoticeTitle string `db:"notice_title"` + NoticeContent string `db:"notice_content"` + NoticeUpdatedAt time.Time `db:"notice_updated_at"` + UpdatedAt time.Time `db:"updated_at"` + }{} + if err := sqlx.GetContext(ctx, a.db, &row, `SELECT * FROM im_mailbox_config WHERE id=1`); err != nil { + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + return nil, err + } + config := &t.MailboxConfig{ + Enabled: row.Enabled, + AllowAnonymous: row.AllowAnonymous, + MaxLength: row.MaxLength, + Notice: t.MailboxNotice{ + Title: row.NoticeTitle, + Content: row.NoticeContent, + UpdatedAt: row.NoticeUpdatedAt, + }, + UpdatedAt: row.UpdatedAt, + } + _ = json.Unmarshal(row.RequiredFields, &config.RequiredFields) + _ = json.Unmarshal(row.AttachmentPolicy, &config.AttachmentPolicy) + return config, nil +} + +func (a *adapter) mailboxSubmissionByIdempotency(ctx context.Context, uid t.Uid, idempotencyKey string) (*t.MailboxSubmission, error) { + row := struct { + SubmissionID string `db:"submission_id"` + }{} + if err := sqlx.GetContext(ctx, a.db, &row, `SELECT submission_id FROM im_mailbox_submissions WHERE sender_id=? AND idempotency_key=?`, decodeUID(uid), idempotencyKey); err != nil { + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + return nil, err + } + return a.mailboxSubmissionById(ctx, row.SubmissionID) +} + +func (a *adapter) mailboxSubmissionById(ctx context.Context, submissionID string) (*t.MailboxSubmission, error) { + row := struct { + ID int64 + SubmissionID string `db:"submission_id"` + SenderID int64 `db:"sender_id"` + Subject string + Content string + Anonymous bool + AttachmentFileIDs []byte `db:"attachment_file_ids"` + NoticeVersion string `db:"notice_version"` + Status string + SubmittedAt time.Time `db:"submitted_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + }{} + if err := sqlx.GetContext(ctx, a.db, &row, `SELECT * FROM im_mailbox_submissions WHERE submission_id=?`, submissionID); err != nil { + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + return nil, err + } + submission := &t.MailboxSubmission{ + ID: row.ID, + SubmissionID: row.SubmissionID, + SenderID: store.EncodeUid(row.SenderID), + Subject: row.Subject, + Content: row.Content, + Anonymous: row.Anonymous, + NoticeVersion: row.NoticeVersion, + Status: row.Status, + SubmittedAt: row.SubmittedAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + _ = json.Unmarshal(row.AttachmentFileIDs, &submission.AttachmentFileIDs) + return submission, nil +} + +func (a *adapter) TenantUserProfileGet(id t.TenantID, uid t.Uid) (*t.UserProfile, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantUserProfileGet(id, uid) + } + } + if err := validTenant(id); err != nil || uid.IsZero() { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(user_id,public_account) VALUES(?,?)`, decodeUID(uid), uid.UserId()); err != nil { + return nil, err + } + return a.userProfileByUID(ctx, uid) +} + +func (a *adapter) TenantUserProfileUpdate(id t.TenantID, uid t.Uid, patch t.UserProfilePatch) (*t.UserProfile, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantUserProfileUpdate(id, uid, patch) + } + } + if err := validTenant(id); err != nil || uid.IsZero() || patch.Version <= 0 { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(user_id,public_account) VALUES(?,?)`, decodeUID(uid), uid.UserId()); err != nil { + return nil, err + } + sets := []string{"version=version+1", "updated_at=CURRENT_TIMESTAMP(3)"} + args := make([]any, 0, 10) + if patch.AvatarFileID != nil { + sets = append(sets, "avatar_file_id=?") + args = append(args, strings.TrimSpace(*patch.AvatarFileID)) + } + if patch.Nickname != nil { + sets = append(sets, "nickname=?") + args = append(args, strings.TrimSpace(*patch.Nickname)) + } + if patch.BirthDate != nil { + birth := strings.TrimSpace(*patch.BirthDate) + if birth == "" { + sets = append(sets, "birth_date=NULL") + } else { + sets = append(sets, "birth_date=?") + args = append(args, birth) + } + } + if patch.Signature != nil { + sets = append(sets, "signature=?") + args = append(args, strings.TrimSpace(*patch.Signature)) + } + if patch.Gender != nil { + sets = append(sets, "gender=?") + args = append(args, strings.TrimSpace(*patch.Gender)) + } + if patch.Region != nil { + sets = append(sets, "region=?") + args = append(args, strings.TrimSpace(*patch.Region)) + } + args = append(args, decodeUID(uid), patch.Version) + res, err := a.db.ExecContext(ctx, `UPDATE im_user_profiles SET `+strings.Join(sets, ",")+` WHERE user_id=? AND version=?`, args...) + if err != nil { + return nil, err + } + affected, _ := res.RowsAffected() + if affected == 0 { + return nil, t.ErrInvalidResponse + } + return a.userProfileByUID(ctx, uid) +} + +func (a *adapter) TenantUserPrivacyGet(id t.TenantID, uid t.Uid) (*t.UserPrivacySettings, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantUserPrivacyGet(id, uid) + } + } + if err := validTenant(id); err != nil || uid.IsZero() { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_privacy_settings(user_id,add_me_methods) VALUES(?,'{"account":true,"phone":true,"qr":true,"group":true}')`, decodeUID(uid)); err != nil { + return nil, err + } + return a.userPrivacyByUID(ctx, uid) +} + +func (a *adapter) TenantUserPrivacyUpdate(id t.TenantID, uid t.Uid, patch t.UserPrivacyPatch) (*t.UserPrivacySettings, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantUserPrivacyUpdate(id, uid, patch) + } + } + if err := validTenant(id); err != nil || uid.IsZero() || patch.Version <= 0 { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_privacy_settings(user_id,add_me_methods) VALUES(?,'{"account":true,"phone":true,"qr":true,"group":true}')`, decodeUID(uid)); err != nil { + return nil, err + } + sets := []string{"version=version+1", "updated_at=CURRENT_TIMESTAMP(3)"} + args := make([]any, 0, 8) + if patch.AddMeMethods != nil { + encoded, err := json.Marshal(patch.AddMeMethods) + if err != nil { + return nil, err + } + sets = append(sets, "add_me_methods=?") + args = append(args, string(encoded)) + } + if patch.ProfileVisibility != nil { + sets = append(sets, "profile_visibility=?") + args = append(args, strings.TrimSpace(*patch.ProfileVisibility)) + } + if patch.LastSeenVisibility != nil { + sets = append(sets, "last_seen_visibility=?") + args = append(args, strings.TrimSpace(*patch.LastSeenVisibility)) + } + if patch.ReadReceipts != nil { + sets = append(sets, "read_receipts=?") + args = append(args, *patch.ReadReceipts) + } + args = append(args, decodeUID(uid), patch.Version) + res, err := a.db.ExecContext(ctx, `UPDATE im_user_privacy_settings SET `+strings.Join(sets, ",")+` WHERE user_id=? AND version=?`, args...) + if err != nil { + return nil, err + } + affected, _ := res.RowsAffected() + if affected == 0 { + return nil, t.ErrInvalidResponse + } + return a.userPrivacyByUID(ctx, uid) +} + +func (a *adapter) TenantAppReleaseLatest(id t.TenantID, platform, channel string) (*t.AppRelease, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantAppReleaseLatest(id, platform, channel) + } + } + platform = strings.ToLower(strings.TrimSpace(platform)) + channel = strings.ToLower(strings.TrimSpace(channel)) + if err := validTenant(id); err != nil || platform == "" || channel == "" { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + row := struct { + ID int64 `db:"id"` + Platform string `db:"platform"` + Channel string `db:"channel"` + LatestVersion string `db:"latest_version"` + MinSupportedVersion string `db:"min_supported_version"` + UpdateType string `db:"update_type"` + ReleaseNotes string `db:"release_notes"` + StoreURL string `db:"store_url"` + PackageURL string `db:"package_url"` + Checksum string `db:"checksum"` + RemindAfterSeconds int `db:"remind_after_seconds"` + PublishedAt time.Time `db:"published_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + }{} + err := sqlx.GetContext(ctx, a.db, &row, + `SELECT id,platform,channel,latest_version,min_supported_version,update_type,release_notes,store_url,package_url,checksum,remind_after_seconds,published_at,created_at,updated_at + FROM im_app_releases WHERE platform=? AND channel=? LIMIT 1`, platform, channel) + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + if err != nil { + return nil, err + } + return &t.AppRelease{ + ID: row.ID, + Platform: row.Platform, + Channel: row.Channel, + LatestVersion: row.LatestVersion, + MinSupportedVersion: row.MinSupportedVersion, + UpdateType: row.UpdateType, + ReleaseNotes: row.ReleaseNotes, + StoreURL: row.StoreURL, + PackageURL: row.PackageURL, + Checksum: row.Checksum, + RemindAfterSeconds: row.RemindAfterSeconds, + PublishedAt: row.PublishedAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + }, nil +} + +func (a *adapter) TenantDevicePushTokenUpsert(id t.TenantID, token *t.DevicePushToken) (*t.DevicePushToken, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantDevicePushTokenUpsert(id, token) + } + } + if err := validTenant(id); err != nil || token == nil || token.UserID.IsZero() || + strings.TrimSpace(token.DeviceID) == "" || strings.TrimSpace(token.Provider) == "" || strings.TrimSpace(token.Token) == "" { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + now := t.TimeNow() + token.DeviceID = strings.TrimSpace(token.DeviceID) + token.Provider = strings.ToLower(strings.TrimSpace(token.Provider)) + token.Token = strings.TrimSpace(token.Token) + token.AppVersion = strings.TrimSpace(token.AppVersion) + token.Platform = strings.ToLower(strings.TrimSpace(token.Platform)) + _, err := a.db.ExecContext(ctx, + `INSERT INTO im_device_push_tokens(device_id,user_id,provider,token,app_version,platform,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE device_id=VALUES(device_id),user_id=VALUES(user_id),provider=VALUES(provider),token=VALUES(token), + app_version=VALUES(app_version),platform=VALUES(platform),updated_at=VALUES(updated_at)`, + token.DeviceID, decodeUID(token.UserID), token.Provider, token.Token, token.AppVersion, token.Platform, now, now) + if err != nil { + return nil, err + } + token.CreatedAt = now + token.UpdatedAt = now + return token, nil +} + +func (a *adapter) userProfileByUID(ctx context.Context, uid t.Uid) (*t.UserProfile, error) { + row := struct { + UserID int64 `db:"user_id"` + AvatarFileID string `db:"avatar_file_id"` + Nickname string + BirthDate sql.NullTime `db:"birth_date"` + Signature string + Gender string + Region string + PublicAccount string `db:"public_account"` + Version int + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + }{} + if err := sqlx.GetContext(ctx, a.db, &row, `SELECT * FROM im_user_profiles WHERE user_id=?`, decodeUID(uid)); err != nil { + return nil, err + } + profile := &t.UserProfile{ + UserID: store.EncodeUid(row.UserID), + AvatarFileID: row.AvatarFileID, + Nickname: row.Nickname, + Signature: row.Signature, + Gender: row.Gender, + Region: row.Region, + PublicAccount: row.PublicAccount, + Version: row.Version, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + if row.BirthDate.Valid { + profile.BirthDate = row.BirthDate.Time.Format("2006-01-02") + } + return profile, nil +} + +func (a *adapter) userPrivacyByUID(ctx context.Context, uid t.Uid) (*t.UserPrivacySettings, error) { + row := struct { + UserID int64 `db:"user_id"` + AddMeMethods []byte `db:"add_me_methods"` + ProfileVisibility string `db:"profile_visibility"` + LastSeenVisibility string `db:"last_seen_visibility"` + ReadReceipts bool `db:"read_receipts"` + Version int + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + }{} + if err := sqlx.GetContext(ctx, a.db, &row, `SELECT * FROM im_user_privacy_settings WHERE user_id=?`, decodeUID(uid)); err != nil { + return nil, err + } + settings := &t.UserPrivacySettings{ + UserID: store.EncodeUid(row.UserID), + AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": true, "group": true}, + ProfileVisibility: row.ProfileVisibility, + LastSeenVisibility: row.LastSeenVisibility, + ReadReceipts: row.ReadReceipts, + Version: row.Version, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + _ = json.Unmarshal(row.AddMeMethods, &settings.AddMeMethods) + return settings, nil +} + +func isAllowedWorkbenchRoute(route string) bool { + switch route { + case "/organization", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox": + return true + default: + return false + } +} + func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int, suffix string) (*t.Message, error) { row := struct { t.Message @@ -3392,6 +5300,116 @@ func (a *adapter) TenantIndependentPasswordUpdateAttempts(id t.TenantID, uid t.U return err } +func (a *adapter) TenantYouthModeGet(id t.TenantID, uid t.Uid) (*t.YouthModeSettings, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantYouthModeGet(id, uid) + } + } + if err := validTenant(id); err != nil || uid.IsZero() { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + settings, err := a.youthModeByUID(ctx, uid) + if err == sql.ErrNoRows { + return defaultYouthModeSettings(uid), nil + } + return settings, err +} + +func (a *adapter) TenantYouthModeSet(id t.TenantID, uid t.Uid, enabled bool, policyVersion int, updatedAt time.Time) (*t.YouthModeSettings, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantYouthModeSet(id, uid, enabled, policyVersion, updatedAt) + } + } + if err := validTenant(id); err != nil || uid.IsZero() || policyVersion <= 0 { + return nil, t.ErrMalformed + } + if updatedAt.IsZero() { + updatedAt = t.TimeNow() + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + var enabledAt *time.Time + if enabled { + enabledAt = &updatedAt + } + res, err := a.db.ExecContext(ctx, + `UPDATE im_youth_mode_settings + SET enabled=?,policy_version=?,enabled_at=?,restrictions=COALESCE(restrictions,'["night_quiet","content_filter","payment_guard"]'),updated_at=? + WHERE user_id=? AND purpose=? AND LENGTH(password_hash)>0`, + enabled, policyVersion, enabledAt, updatedAt, decodeUID(uid), "youth_mode") + if err != nil { + return nil, err + } + affected, _ := res.RowsAffected() + if affected == 0 { + return nil, t.ErrMalformed + } + return a.youthModeByUID(ctx, uid) +} + +func (a *adapter) youthModeByUID(ctx context.Context, uid t.Uid) (*t.YouthModeSettings, error) { + row := struct { + UserID int64 `db:"user_id"` + PasswordHash []byte `db:"password_hash"` + Enabled bool `db:"enabled"` + PolicyVersion int `db:"policy_version"` + Restrictions sql.NullString `db:"restrictions"` + EnabledAt *time.Time `db:"enabled_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + }{} + if err := sqlx.GetContext(ctx, a.db, &row, + `SELECT user_id,password_hash,enabled,policy_version,restrictions,enabled_at,created_at,updated_at + FROM im_youth_mode_settings WHERE user_id=? AND purpose=?`, decodeUID(uid), "youth_mode"); err != nil { + return nil, err + } + settings := &t.YouthModeSettings{ + UserID: store.EncodeUid(row.UserID), + Enabled: row.Enabled, + PolicyVersion: row.PolicyVersion, + Restrictions: []string{"night_quiet", "content_filter", "payment_guard"}, + PasswordSet: len(row.PasswordHash) > 0, + EnabledAt: row.EnabledAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + if row.Restrictions.Valid && row.Restrictions.String != "" { + _ = json.Unmarshal([]byte(row.Restrictions.String), &settings.Restrictions) + } + settings.CanEnable = settings.PasswordSet && !settings.Enabled + return settings, nil +} + +func defaultYouthModeSettings(uid t.Uid) *t.YouthModeSettings { + now := t.TimeNow() + return &t.YouthModeSettings{ + UserID: uid, + Enabled: false, + PolicyVersion: 1, + Restrictions: []string{"night_quiet", "content_filter", "payment_guard"}, + PasswordSet: false, + CanEnable: false, + CreatedAt: now, + UpdatedAt: now, + } +} + func (a *adapter) TenantSystemNotificationCreate(id t.TenantID, notification *t.SystemNotification) error { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) diff --git a/server/im_http.go b/server/im_http.go index 6d673d4..dd72bb2 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -121,6 +121,18 @@ type imIndependentPasswordVerifyRequest struct { Action string `json:"action,omitempty"` } +type imYouthModeEnableRequest struct { + PolicyVersion int `json:"policy_version"` + ActionToken string `json:"action_token"` +} + +type imDevicePushTokenRequest struct { + Provider string `json:"provider"` + Token string `json:"token"` + AppVersion string `json:"app_version,omitempty"` + Platform string `json:"platform,omitempty"` +} + type imSystemNotificationReadRequest struct { ReadAt string `json:"read_at,omitempty"` } @@ -865,6 +877,1021 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) { }) } +func serveIMWorkbench(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + modules, layoutVersion, err := store.Workbench.Get(principal.TenantID, principal.Uid) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "layout_version": layoutVersion, + "modules": workbenchModulePayloads(modules), + "enterprise": map[string]any{ + "tenant_code": strconv.FormatInt(int64(principal.TenantID), 10), + "feature_flags": map[string]any{ + "organization": true, + "meetings": true, + "daily_reports": true, + "monthly_reports": true, + "executive_mailbox": true, + }, + }, + }) +} + +func serveIMOrganizationDepartments(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) + parentID := strings.TrimSpace(req.URL.Query().Get("parent_id")) + if parentID == "" { + parentID = "root" + } + items, nextCursor, err := store.Organization.ListDepartments(principal.TenantID, principal.Uid, parentID, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "items": organizationDepartmentPayloads(items), + "next_cursor": nextCursor, + }) +} + +func serveIMOrganizationDepartmentResource(wrt http.ResponseWriter, req *http.Request) { + departmentID, tail, ok := organizationDepartmentResourcePath(req.URL.Path) + if !ok || tail != "members" { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "organization route not found", nil) + return + } + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) + items, nextCursor, err := store.Organization.ListMembers(principal.TenantID, principal.Uid, departmentID, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "department_id": departmentID, + "items": organizationMemberPayloads(items), + "next_cursor": nextCursor, + }) +} + +func serveIMOrganizationSearch(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + query := strings.TrimSpace(req.URL.Query().Get("q")) + if query == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "q"}) + return + } + limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100) + items, nextCursor, err := store.Organization.Search(principal.TenantID, principal.Uid, query, strings.TrimSpace(req.URL.Query().Get("types")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "items": organizationSearchPayloads(items), + "next_cursor": nextCursor, + }) +} + +func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) + if !ok { + return + } + var body struct { + Title string `json:"title"` + StartAt string `json:"start_at"` + EndAt string `json:"end_at"` + Location string `json:"location"` + AttendeeUserIDs []string `json:"attendee_user_ids"` + ReminderMinutes []int `json:"reminder_minutes"` + } + if !decodeIMJSONBody(wrt, req, &body) { + return + } + startAt, err := parseIMTime(body.StartAt) + if err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "start_at"}) + return + } + endAt, err := parseIMTime(body.EndAt) + if err != nil || !endAt.After(startAt) { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "end_at"}) + return + } + meetingID, err := randomIMToken(12) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + attendeeIDs, ok := parseIMUserIDList(wrt, req, body.AttendeeUserIDs, "attendee_user_ids") + if !ok { + return + } + meeting, err := store.Meetings.Create(principal.TenantID, principal.Uid, &types.Meeting{ + MeetingID: "mtg_" + meetingID, + OrganizerID: principal.Uid, + Title: strings.TrimSpace(body.Title), + StartAt: startAt, + EndAt: endAt, + Location: strings.TrimSpace(body.Location), + ReminderMinutes: body.ReminderMinutes, + IdempotencyKey: idempotencyKey, + }, attendeeIDs) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) +} + +func serveIMMeetingResource(wrt http.ResponseWriter, req *http.Request) { + meetingID, tail, ok := meetingResourcePath(req.URL.Path) + if !ok { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "meeting route not found", nil) + return + } + switch tail { + case "": + if req.Method == http.MethodGet { + serveIMMeetingGet(wrt, req, meetingID) + return + } + if req.Method == http.MethodPatch { + serveIMMeetingUpdate(wrt, req, meetingID) + return + } + case "cancel": + serveIMMeetingCancel(wrt, req, meetingID) + return + case "reminders": + serveIMMeetingReminders(wrt, req, meetingID) + return + case "my-response": + serveIMMeetingMyResponse(wrt, req, meetingID) + return + } + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) +} + +func serveIMMeetingGet(wrt http.ResponseWriter, req *http.Request, meetingID string) { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + meeting, err := store.Meetings.Get(principal.TenantID, principal.Uid, meetingID) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) +} + +func serveIMMeetingUpdate(wrt http.ResponseWriter, req *http.Request, meetingID string) { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) + if !ok { + return + } + var body struct { + Title *string `json:"title"` + StartAt *string `json:"start_at"` + EndAt *string `json:"end_at"` + Location *string `json:"location"` + ReminderMinutes []int `json:"reminder_minutes"` + Version int `json:"version"` + } + if !decodeIMJSONBody(wrt, req, &body) { + return + } + patch := types.MeetingPatch{Title: body.Title, Location: body.Location, ReminderMinutes: body.ReminderMinutes, Version: body.Version} + if body.StartAt != nil { + startAt, err := parseIMTime(*body.StartAt) + if err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "start_at"}) + return + } + patch.StartAt = &startAt + } + if body.EndAt != nil { + endAt, err := parseIMTime(*body.EndAt) + if err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "end_at"}) + return + } + patch.EndAt = &endAt + } + meeting, err := store.Meetings.Update(principal.TenantID, principal.Uid, meetingID, patch, idempotencyKey) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) +} + +func serveIMMeetingCancel(wrt http.ResponseWriter, req *http.Request, meetingID string) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) + if !ok { + return + } + meeting, err := store.Meetings.Cancel(principal.TenantID, principal.Uid, meetingID, idempotencyKey) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) +} + +func serveIMMeetingReminders(wrt http.ResponseWriter, req *http.Request, meetingID string) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) + if !ok { + return + } + participants, err := store.Meetings.Remind(principal.TenantID, principal.Uid, meetingID, idempotencyKey) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "meeting_id": meetingID, + "reminded": meetingParticipantPayloads(participants), + }) +} + +func serveIMMeetingMyResponse(wrt http.ResponseWriter, req *http.Request, meetingID string) { + if req.Method != http.MethodPut { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + var body struct { + Response string `json:"response"` + Version int `json:"version"` + } + if !decodeIMJSONBody(wrt, req, &body) { + return + } + meeting, err := store.Meetings.Respond(principal.TenantID, principal.Uid, meetingID, strings.TrimSpace(body.Response), body.Version) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) +} + +func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + switch req.Method { + case http.MethodGet: + limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) + reports, nextCursor, err := store.DailyReports.List(principal.TenantID, principal.Uid, + req.URL.Query().Get("scope"), + req.URL.Query().Get("from"), + req.URL.Query().Get("to"), + req.URL.Query().Get("status"), + req.URL.Query().Get("cursor"), + limit) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "items": dailyReportListPayloads(reports), + "next_cursor": nextCursor, + "permissions": map[string]any{ + "can_create": true, + "makeup_allowed_days": 7, + "can_view_managed": true, + "can_remind_unsubmitted": true, + }, + }) + case http.MethodPost: + var body struct { + ReportDate string `json:"report_date"` + } + if !decodeIMJSONBody(wrt, req, &body) { + return + } + reportDate, err := parseIMDate(body.ReportDate) + if err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "report_date"}) + return + } + token, err := randomIMToken(12) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + report, err := store.DailyReports.CreateDraft(principal.TenantID, principal.Uid, "dr_"+token, reportDate) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid)) + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + +func serveIMDailyReportResource(wrt http.ResponseWriter, req *http.Request) { + reportID, tail, ok := dailyReportResourcePath(req.URL.Path) + if !ok { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "daily report route not found", nil) + return + } + if reportID == "statistics" && tail == "" { + serveIMDailyReportStatistics(wrt, req) + return + } + if reportID == "reminders" && tail == "" { + serveIMDailyReportReminders(wrt, req) + return + } + switch tail { + case "": + if req.Method == http.MethodGet { + serveIMDailyReportGet(wrt, req, reportID) + return + } + case "draft": + serveIMDailyReportSaveDraft(wrt, req, reportID) + return + case "submit": + serveIMDailyReportSubmit(wrt, req, reportID) + return + } + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) +} + +func serveIMDailyReportGet(wrt http.ResponseWriter, req *http.Request, reportID string) { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + report, err := store.DailyReports.Get(principal.TenantID, principal.Uid, reportID) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid)) +} + +func serveIMDailyReportSaveDraft(wrt http.ResponseWriter, req *http.Request, reportID string) { + if req.Method != http.MethodPut { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + var body struct { + Content string `json:"content"` + Attachments []string `json:"attachments"` + Version int `json:"version"` + } + if !decodeIMJSONBody(wrt, req, &body) { + return + } + report, err := store.DailyReports.SaveDraft(principal.TenantID, principal.Uid, reportID, body.Content, body.Attachments, body.Version) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid)) +} + +func serveIMDailyReportSubmit(wrt http.ResponseWriter, req *http.Request, reportID string) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + var body struct { + Version int `json:"version"` + } + if !decodeIMJSONBody(wrt, req, &body) { + return + } + report, err := store.DailyReports.Submit(principal.TenantID, principal.Uid, reportID, body.Version) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid)) +} + +func serveIMDailyReportStatistics(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + date, err := parseIMDate(req.URL.Query().Get("date")) + if err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"}) + return + } + stats, err := store.DailyReports.Stats(principal.TenantID, principal.Uid, date, req.URL.Query().Get("department_id")) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "expected_count": stats.ExpectedCount, + "submitted_count": stats.SubmittedCount, + "missing_count": stats.MissingCount, + "scope": map[string]any{"department_id": strings.TrimSpace(req.URL.Query().Get("department_id"))}, + }) +} + +func serveIMDailyReportReminders(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) + if !ok { + return + } + var body struct { + Date string `json:"date"` + UserIDs []string `json:"user_ids"` + } + if !decodeIMJSONBody(wrt, req, &body) { + return + } + date, err := parseIMDate(body.Date) + if err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"}) + return + } + userIDs, ok := parseIMUserIDList(wrt, req, body.UserIDs, "user_ids") + if !ok { + return + } + reminded, err := store.DailyReports.Remind(principal.TenantID, principal.Uid, date, userIDs, idempotencyKey) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + items := make([]map[string]any, 0, len(reminded)) + for _, uid := range reminded { + items = append(items, map[string]any{"user_id": uid.UserId(), "status": "queued"}) + } + writeIMSuccess(wrt, req, map[string]any{"date": date.Format("2006-01-02"), "items": items}) +} + +func serveIMMonthlyReports(wrt http.ResponseWriter, req *http.Request) { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + switch req.Method { + case http.MethodGet: + limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) + reports, nextCursor, err := store.MonthlyReports.List(principal.TenantID, principal.Uid, + req.URL.Query().Get("scope"), + req.URL.Query().Get("from_month"), + req.URL.Query().Get("to_month"), + req.URL.Query().Get("status"), + req.URL.Query().Get("cursor"), + limit) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{"items": monthlyReportListPayloads(reports), "next_cursor": nextCursor}) + case http.MethodPost: + var body struct { + Month string `json:"month"` + } + if !decodeIMJSONBody(wrt, req, &body) { + return + } + if !validIMMonth(body.Month) { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "month"}) + return + } + token, err := randomIMToken(12) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + report, err := store.MonthlyReports.CreateDraft(principal.TenantID, principal.Uid, "mr_"+token, strings.TrimSpace(body.Month)) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid)) + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + +func serveIMMonthlyReportResource(wrt http.ResponseWriter, req *http.Request) { + reportID, tail, ok := monthlyReportResourcePath(req.URL.Path) + if !ok { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "monthly report route not found", nil) + return + } + if reportID == "statistics" && tail == "" { + serveIMMonthlyReportStatistics(wrt, req) + return + } + switch tail { + case "": + if req.Method == http.MethodGet { + serveIMMonthlyReportGet(wrt, req, reportID) + return + } + case "draft": + serveIMMonthlyReportSaveDraft(wrt, req, reportID) + return + case "submit": + serveIMMonthlyReportSubmit(wrt, req, reportID) + return + } + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) +} + +func serveIMMonthlyReportGet(wrt http.ResponseWriter, req *http.Request, reportID string) { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + report, err := store.MonthlyReports.Get(principal.TenantID, principal.Uid, reportID) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid)) +} + +func serveIMMonthlyReportSaveDraft(wrt http.ResponseWriter, req *http.Request, reportID string) { + if req.Method != http.MethodPut { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + var body struct { + WorkSummary string `json:"work_summary"` + NextPlan string `json:"next_plan"` + Attachments []string `json:"attachments"` + Version int `json:"version"` + } + if !decodeIMJSONBody(wrt, req, &body) { + return + } + report, err := store.MonthlyReports.SaveDraft(principal.TenantID, principal.Uid, reportID, body.WorkSummary, body.NextPlan, body.Attachments, body.Version) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid)) +} + +func serveIMMonthlyReportSubmit(wrt http.ResponseWriter, req *http.Request, reportID string) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + var body struct { + Version int `json:"version"` + } + if !decodeIMJSONBody(wrt, req, &body) { + return + } + report, err := store.MonthlyReports.Submit(principal.TenantID, principal.Uid, reportID, body.Version) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid)) +} + +func serveIMMonthlyReportStatistics(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + month := strings.TrimSpace(req.URL.Query().Get("month")) + if !validIMMonth(month) { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "month"}) + return + } + stats, err := store.MonthlyReports.Stats(principal.TenantID, principal.Uid, month, req.URL.Query().Get("department_id")) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "expected_count": stats.ExpectedCount, + "submitted_count": stats.SubmittedCount, + "missing_count": stats.MissingCount, + "scope": map[string]any{"department_id": strings.TrimSpace(req.URL.Query().Get("department_id"))}, + }) +} + +func serveIMMailboxConfig(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + config, err := store.Mailbox.Config(principal.TenantID, principal.Uid) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, mailboxConfigPayload(config)) +} + +func serveIMMailboxSubmissions(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) + if !ok { + return + } + var body struct { + Subject string `json:"subject"` + Content string `json:"content"` + Anonymous bool `json:"anonymous"` + AttachmentFileIDs []string `json:"attachment_file_ids"` + NoticeVersion string `json:"notice_version"` + } + if !decodeIMJSONBody(wrt, req, &body) { + return + } + token, err := randomIMToken(12) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + submission, err := store.Mailbox.Submit(principal.TenantID, principal.Uid, &types.MailboxSubmission{ + SubmissionID: "mb_" + token, + SenderID: principal.Uid, + Subject: strings.TrimSpace(body.Subject), + Content: strings.TrimSpace(body.Content), + Anonymous: body.Anonymous, + AttachmentFileIDs: body.AttachmentFileIDs, + NoticeVersion: strings.TrimSpace(body.NoticeVersion), + }, idempotencyKey) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, mailboxSubmissionResultPayload(submission)) +} + +func serveIMMe(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + profile, err := store.Profiles.Get(principal.TenantID, principal.Uid) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "user_id": principal.Uid.UserId(), + "profile": userProfilePayload10(profile), + "profile_version": profile.Version, + "realname": map[string]any{"status": "unknown"}, + "youth_mode": map[string]any{"enabled": false}, + }) +} + +func serveIMMeProfile(wrt http.ResponseWriter, req *http.Request) { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + switch req.Method { + case http.MethodGet: + profile, err := store.Profiles.Get(principal.TenantID, principal.Uid) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, userProfilePayload10(profile)) + case http.MethodPatch: + var body struct { + AvatarFileID *string `json:"avatar_file_id"` + Nickname *string `json:"nickname"` + BirthDate *string `json:"birth_date"` + Signature *string `json:"signature"` + Gender *string `json:"gender"` + Region *string `json:"region"` + Version int `json:"version"` + } + if !decodeIMJSONBody(wrt, req, &body) { + return + } + if body.BirthDate != nil && strings.TrimSpace(*body.BirthDate) != "" { + if date, err := parseIMDate(*body.BirthDate); err != nil || date.After(time.Now().UTC()) { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "birth_date"}) + return + } + } + profile, err := store.Profiles.Update(principal.TenantID, principal.Uid, types.UserProfilePatch{ + AvatarFileID: body.AvatarFileID, + Nickname: body.Nickname, + BirthDate: body.BirthDate, + Signature: body.Signature, + Gender: body.Gender, + Region: body.Region, + Version: body.Version, + }) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, userProfilePayload10(profile)) + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + +func serveIMMePrivacy(wrt http.ResponseWriter, req *http.Request) { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + switch req.Method { + case http.MethodGet: + settings, err := store.Profiles.GetPrivacy(principal.TenantID, principal.Uid) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, userPrivacyPayload(settings)) + case http.MethodPatch: + var body struct { + AddMeMethods map[string]bool `json:"add_me_methods"` + ProfileVisibility *string `json:"profile_visibility"` + LastSeenVisibility *string `json:"last_seen_visibility"` + ReadReceipts *bool `json:"read_receipts"` + Version int `json:"version"` + } + if !decodeIMJSONBody(wrt, req, &body) { + return + } + settings, err := store.Profiles.UpdatePrivacy(principal.TenantID, principal.Uid, types.UserPrivacyPatch{ + AddMeMethods: body.AddMeMethods, + ProfileVisibility: body.ProfileVisibility, + LastSeenVisibility: body.LastSeenVisibility, + ReadReceipts: body.ReadReceipts, + Version: body.Version, + }) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, userPrivacyPayload(settings)) + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + +func serveIMAppReleaseLatest(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + tenant, ok := resolveIMHTTPTenant(wrt, req, "default") + if !ok { + return + } + platform := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("platform"))) + channel := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("channel"))) + if channel == "" { + channel = "stable" + } + if platform != "ios" && platform != "android" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "platform"}) + return + } + release, err := store.AppReleases.Latest(tenant.ID, platform, channel) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, appReleasePayload(release)) +} + +func serveIMYouthMode(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + settings, err := store.Security.GetYouthMode(principal.TenantID, principal.Uid) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, youthModePayload(settings)) +} + +func serveIMYouthModeEnable(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + var body imYouthModeEnableRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + if body.PolicyVersion <= 0 || strings.TrimSpace(body.ActionToken) == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) + return + } + if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, strings.TrimSpace(body.ActionToken), "set_independent_password", "youth_mode", time.Now().UTC().Round(time.Millisecond)); err != nil { + writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil) + return + } + settings, err := store.Security.SetYouthMode(principal.TenantID, principal.Uid, true, body.PolicyVersion, time.Now().UTC().Round(time.Millisecond)) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, youthModePayload(settings)) +} + +func serveIMYouthModeDisable(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + actionToken := strings.TrimSpace(req.Header.Get("X-Action-Token")) + if actionToken == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "X-Action-Token"}) + return + } + if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, actionToken, "close_youth_mode", "youth_mode", time.Now().UTC().Round(time.Millisecond)); err != nil { + writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil) + return + } + current, err := store.Security.GetYouthMode(principal.TenantID, principal.Uid) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + settings, err := store.Security.SetYouthMode(principal.TenantID, principal.Uid, false, current.PolicyVersion, time.Now().UTC().Round(time.Millisecond)) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, youthModePayload(settings)) +} + +func serveIMDevicePushToken(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPut { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + deviceID := strings.TrimSpace(strings.TrimPrefix(req.URL.Path, "/v1/im/devices/")) + deviceID = strings.TrimSuffix(deviceID, "/push-token") + if deviceID == "" || strings.Contains(deviceID, "/") { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "device_id"}) + return + } + var body imDevicePushTokenRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + token, err := store.DevicePushTokens.Upsert(principal.TenantID, &types.DevicePushToken{ + UserID: principal.Uid, + DeviceID: deviceID, + Provider: body.Provider, + Token: body.Token, + AppVersion: body.AppVersion, + Platform: body.Platform, + }) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, devicePushTokenPayload(token)) +} + func serveIMConversationSearch(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -1186,6 +2213,8 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) { func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) { switch { + case strings.HasSuffix(req.URL.Path, "/calls"): + serveIMTopicCalls(wrt, req) case strings.HasSuffix(req.URL.Path, "/messages/search"): serveIMMessageSearch(wrt, req) case strings.Contains(req.URL.Path, "/messages/") && strings.HasSuffix(req.URL.Path, "/context"): @@ -1203,6 +2232,42 @@ func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) { } } +func serveIMCalls(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) + items, nextCursor, err := store.Calls.List(principal.TenantID, principal.Uid, "", strings.TrimSpace(req.URL.Query().Get("cursor")), limit) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{"items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor}) +} + +func serveIMTopicCalls(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, topic, ok := authenticateTopicReader(wrt, req, topicFromCallsPath(req.URL.Path)) + if !ok { + return + } + limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) + items, nextCursor, err := store.Calls.List(principal.TenantID, principal.Uid, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor}) +} + func serveIMMessageSearch(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -3326,6 +4391,56 @@ func parseIMLimit(raw string, fallback, max int) int { return limit } +func parseIMTime(raw string) (time.Time, error) { + parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(raw)) + if err != nil { + return time.Time{}, err + } + return parsed.UTC().Round(time.Millisecond), nil +} + +func parseIMDate(raw string) (time.Time, error) { + parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(raw), time.UTC) + if err != nil { + return time.Time{}, err + } + return parsed, nil +} + +func validIMMonth(raw string) bool { + if _, err := time.ParseInLocation("2006-01", strings.TrimSpace(raw), time.UTC); err != nil { + return false + } + return true +} + +func requireIMIdempotencyKey(wrt http.ResponseWriter, req *http.Request) (string, bool) { + key := strings.TrimSpace(req.Header.Get("Idempotency-Key")) + if key == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"}) + return "", false + } + return key, true +} + +func parseIMUserIDList(wrt http.ResponseWriter, req *http.Request, values []string, field string) ([]types.Uid, bool) { + uids := make([]types.Uid, 0, len(values)) + seen := make(map[types.Uid]bool, len(values)) + for _, value := range values { + uid := types.ParseUserId(strings.TrimSpace(value)) + if uid.IsZero() { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": field}) + return nil, false + } + if seen[uid] { + continue + } + seen[uid] = true + uids = append(uids, uid) + } + return uids, true +} + func parseIMNonNegativeCursor(raw string) (int, error) { raw = strings.TrimSpace(raw) if raw == "" { @@ -3925,6 +5040,401 @@ func mediaMessagePayload(msg types.Message) map[string]any { return data } +func callRecordPayloads(records []types.CallRecord, viewer types.Uid) []map[string]any { + items := make([]map[string]any, 0, len(records)) + for _, record := range records { + items = append(items, callRecordPayload(record, viewer)) + } + return items +} + +func callRecordPayload(record types.CallRecord, viewer types.Uid) map[string]any { + peer := record.CalleeID + direction := "outgoing" + if record.CalleeID == viewer { + peer = record.CallerID + direction = "incoming" + } + data := map[string]any{ + "call_id": record.CallID, + "topic": record.Topic, + "seq": record.SeqID, + "caller_id": record.CallerID.UserId(), + "callee_id": record.CalleeID.UserId(), + "peer_user_id": peer.UserId(), + "direction": direction, + "call_type": record.CallType, + "status": record.Status, + "result": record.Status, + "duration_ms": record.DurationMS, + "started_at": record.StartedAt.UTC().Format(time.RFC3339Nano), + "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), + "updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano), + } + if record.EndReason != "" { + data["end_reason"] = record.EndReason + } + if record.AcceptedAt != nil { + data["accepted_at"] = record.AcceptedAt.UTC().Format(time.RFC3339Nano) + } + if record.FinishedAt != nil { + data["finished_at"] = record.FinishedAt.UTC().Format(time.RFC3339Nano) + } + return data +} + +func workbenchModulePayloads(modules []types.WorkbenchModule) []map[string]any { + items := make([]map[string]any, 0, len(modules)) + for _, module := range modules { + if !isAllowedIMWorkbenchRoute(module.Route) { + continue + } + items = append(items, map[string]any{ + "code": module.Code, + "name": module.Name, + "icon_file_id": module.IconFileID, + "route": module.Route, + "unread_count": 0, + "enabled": module.Enabled, + "permission": module.Permission, + }) + } + return items +} + +func organizationDepartmentPayloads(departments []types.OrganizationDepartment) []map[string]any { + items := make([]map[string]any, 0, len(departments)) + for _, department := range departments { + items = append(items, organizationDepartmentPayload(department)) + } + return items +} + +func organizationDepartmentPayload(department types.OrganizationDepartment) map[string]any { + return map[string]any{ + "department_id": department.DepartmentID, + "name": department.Name, + "parent_id": department.ParentID, + "child_count": department.ChildCount, + "member_count": department.MemberCount, + "version": department.Version, + } +} + +func organizationMemberPayloads(members []types.OrganizationMember) []map[string]any { + items := make([]map[string]any, 0, len(members)) + for _, member := range members { + items = append(items, organizationMemberPayload(member)) + } + return items +} + +func organizationMemberPayload(member types.OrganizationMember) map[string]any { + payload := map[string]any{ + "user_id": member.UserID.UserId(), + "department_id": member.DepartmentID, + "display_name": member.DisplayName, + "position": member.Position, + "avatar_file_id": member.AvatarFileID, + "status": member.Status, + "contactable": member.Contactable, + "version": member.Version, + } + if member.Mobile != "" { + payload["mobile"] = member.Mobile + } + if member.Email != "" { + payload["email"] = member.Email + } + return payload +} + +func organizationSearchPayloads(items []types.OrganizationSearchItem) []map[string]any { + payload := make([]map[string]any, 0, len(items)) + for _, item := range items { + switch item.Type { + case "department": + if item.Department != nil { + payload = append(payload, map[string]any{ + "type": "department", + "department": organizationDepartmentPayload(*item.Department), + }) + } + case "user": + if item.Member != nil { + payload = append(payload, map[string]any{ + "type": "user", + "user": organizationMemberPayload(*item.Member), + }) + } + } + } + return payload +} + +func meetingPayload(meeting *types.Meeting, viewer types.Uid) map[string]any { + participants := meetingParticipantPayloads(meeting.Participants) + data := map[string]any{ + "meeting_id": meeting.MeetingID, + "title": meeting.Title, + "organizer_id": meeting.OrganizerID.UserId(), + "start_at": meeting.StartAt.UTC().Format(time.RFC3339Nano), + "end_at": meeting.EndAt.UTC().Format(time.RFC3339Nano), + "location": meeting.Location, + "state": meeting.State, + "reminder_minutes": meeting.ReminderMinutes, + "version": meeting.Version, + "participants": participants, + "permissions": meetingPermissionsPayload(meeting, viewer), + "created_at": meeting.CreatedAt.UTC().Format(time.RFC3339Nano), + "updated_at": meeting.UpdatedAt.UTC().Format(time.RFC3339Nano), + } + if meeting.CanceledAt != nil { + data["canceled_at"] = meeting.CanceledAt.UTC().Format(time.RFC3339Nano) + } + for _, participant := range meeting.Participants { + if participant.UserID == viewer { + data["my_response"] = participant.Response + break + } + } + return data +} + +func meetingParticipantPayloads(participants []types.MeetingParticipant) []map[string]any { + items := make([]map[string]any, 0, len(participants)) + for _, participant := range participants { + item := map[string]any{ + "user_id": participant.UserID.UserId(), + "response": participant.Response, + } + if participant.ResponseAt != nil { + item["response_at"] = participant.ResponseAt.UTC().Format(time.RFC3339Nano) + } + items = append(items, item) + } + return items +} + +func meetingPermissionsPayload(meeting *types.Meeting, viewer types.Uid) map[string]any { + isOrganizer := meeting.OrganizerID == viewer + isScheduled := meeting.State == types.MeetingStateScheduled + canRespond := false + for _, participant := range meeting.Participants { + if participant.UserID == viewer && !isOrganizer { + canRespond = true + break + } + } + if time.Now().UTC().After(meeting.EndAt) { + canRespond = false + } + return map[string]any{ + "can_update": isOrganizer && isScheduled, + "can_cancel": isOrganizer && isScheduled, + "can_remind": isOrganizer && isScheduled, + "can_respond": canRespond && isScheduled, + } +} + +func dailyReportListPayloads(reports []types.DailyReport) []map[string]any { + items := make([]map[string]any, 0, len(reports)) + for _, report := range reports { + items = append(items, dailyReportListPayload(report)) + } + return items +} + +func dailyReportListPayload(report types.DailyReport) map[string]any { + data := map[string]any{ + "report_id": report.ReportID, + "author_id": report.AuthorID.UserId(), + "report_date": report.ReportDate.Format("2006-01-02"), + "status": report.Status, + "draft_updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano), + "read_state": report.ReadState, + "version": report.Version, + } + if report.SubmittedAt != nil { + data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano) + } + return data +} + +func dailyReportDetailPayload(report *types.DailyReport, viewer types.Uid) map[string]any { + data := dailyReportListPayload(*report) + data["content"] = report.Content + data["attachments"] = report.Attachments + data["updated_at"] = report.UpdatedAt.UTC().Format(time.RFC3339Nano) + data["created_at"] = report.CreatedAt.UTC().Format(time.RFC3339Nano) + data["permissions"] = map[string]any{ + "can_edit": report.AuthorID == viewer && report.Status == types.DailyReportStatusDraft, + "can_submit": report.AuthorID == viewer && report.Status == types.DailyReportStatusDraft, + "can_view": report.AuthorID == viewer, + } + return data +} + +func monthlyReportListPayloads(reports []types.MonthlyReport) []map[string]any { + items := make([]map[string]any, 0, len(reports)) + for _, report := range reports { + items = append(items, monthlyReportListPayload(report)) + } + return items +} + +func monthlyReportListPayload(report types.MonthlyReport) map[string]any { + action := "view" + if report.Status == types.DailyReportStatusDraft { + action = "edit" + } + data := map[string]any{ + "report_id": report.ReportID, + "author_id": report.AuthorID.UserId(), + "month": report.Month, + "status": report.Status, + "read_state": report.ReadState, + "version": report.Version, + "action": action, + "updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano), + } + if report.SubmittedAt != nil { + data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano) + } + return data +} + +func monthlyReportDetailPayload(report *types.MonthlyReport, viewer types.Uid) map[string]any { + data := monthlyReportListPayload(*report) + data["work_summary"] = report.WorkSummary + data["next_plan"] = report.NextPlan + data["attachments"] = report.Attachments + data["created_at"] = report.CreatedAt.UTC().Format(time.RFC3339Nano) + data["permissions"] = map[string]any{ + "can_edit": report.AuthorID == viewer && report.Status == types.DailyReportStatusDraft, + "can_submit": report.AuthorID == viewer && report.Status == types.DailyReportStatusDraft, + "can_view": report.AuthorID == viewer, + } + data["validation"] = map[string]any{ + "work_summary_required": true, + "next_plan_required": true, + "max_length": 10000, + } + return data +} + +func mailboxConfigPayload(config *types.MailboxConfig) map[string]any { + return map[string]any{ + "enabled": config.Enabled, + "allow_anonymous": config.AllowAnonymous, + "required_fields": config.RequiredFields, + "max_length": config.MaxLength, + "attachment_policy": config.AttachmentPolicy, + "notice": map[string]any{ + "title": config.Notice.Title, + "content": config.Notice.Content, + "updated_at": config.Notice.UpdatedAt.UTC().Format(time.RFC3339Nano), + "version": config.Notice.UpdatedAt.UTC().Format(time.RFC3339Nano), + }, + } +} + +func mailboxSubmissionResultPayload(submission *types.MailboxSubmission) map[string]any { + return map[string]any{ + "submission_id": submission.SubmissionID, + "status": submission.Status, + "submitted_at": submission.SubmittedAt.UTC().Format(time.RFC3339Nano), + } +} + +func userProfilePayload10(profile *types.UserProfile) map[string]any { + data := map[string]any{ + "avatar_file_id": profile.AvatarFileID, + "nickname": profile.Nickname, + "birth_date": profile.BirthDate, + "signature": profile.Signature, + "gender": profile.Gender, + "region": profile.Region, + "public_account": profile.PublicAccount, + "version": profile.Version, + "updated_at": profile.UpdatedAt.UTC().Format(time.RFC3339Nano), + "field_rules": map[string]any{ + "nickname": map[string]any{"min_length": 1, "max_length": 32}, + "signature": map[string]any{"max_length": 120}, + "birth_date": map[string]any{"format": "YYYY-MM-DD"}, + "gender": []string{"male", "female", "other", ""}, + }, + } + return data +} + +func userPrivacyPayload(settings *types.UserPrivacySettings) map[string]any { + return map[string]any{ + "add_me_methods": settings.AddMeMethods, + "profile_visibility": settings.ProfileVisibility, + "last_seen_visibility": settings.LastSeenVisibility, + "read_receipts": settings.ReadReceipts, + "version": settings.Version, + "updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano), + "editable": map[string]any{ + "add_me_methods": true, + "profile_visibility": true, + "last_seen_visibility": true, + "read_receipts": true, + }, + } +} + +func appReleasePayload(release *types.AppRelease) map[string]any { + return map[string]any{ + "platform": release.Platform, + "channel": release.Channel, + "latest_version": release.LatestVersion, + "min_supported_version": release.MinSupportedVersion, + "update_type": release.UpdateType, + "release_notes": release.ReleaseNotes, + "store_url": release.StoreURL, + "package_url": release.PackageURL, + "checksum": release.Checksum, + "remind_after_seconds": release.RemindAfterSeconds, + "published_at": release.PublishedAt.UTC().Format(time.RFC3339Nano), + } +} + +func youthModePayload(settings *types.YouthModeSettings) map[string]any { + data := map[string]any{ + "enabled": settings.Enabled, + "policy_version": settings.PolicyVersion, + "restrictions": settings.Restrictions, + "password_set": settings.PasswordSet, + "can_enable": settings.CanEnable, + "updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano), + } + if settings.EnabledAt != nil { + data["enabled_at"] = settings.EnabledAt.UTC().Format(time.RFC3339Nano) + } + return data +} + +func devicePushTokenPayload(token *types.DevicePushToken) map[string]any { + return map[string]any{ + "device_id": token.DeviceID, + "provider": token.Provider, + "app_version": token.AppVersion, + "platform": token.Platform, + "updated_at": token.UpdatedAt.UTC().Format(time.RFC3339Nano), + } +} + +func isAllowedIMWorkbenchRoute(route string) bool { + switch route { + case "/organization", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox": + return true + default: + return false + } +} + func relationshipContextPayload(ctx *types.RelationshipContext) map[string]any { data := map[string]any{ "topic": ctx.Topic, @@ -4130,6 +5640,8 @@ func writeIMRelationError(wrt http.ResponseWriter, req *http.Request, err error) writeIMError(wrt, req, http.StatusNotFound, 20004, imReasonMalformedRequest, "resource not found", nil) case errors.Is(err, types.ErrPermissionDenied): writeIMError(wrt, req, http.StatusForbidden, 20005, imReasonPermissionDenied, "permission denied", nil) + case errors.Is(err, types.ErrInvalidResponse): + writeIMError(wrt, req, http.StatusConflict, 20009, "VERSION_CONFLICT", "version conflict", nil) case errors.Is(err, types.ErrMalformed): writeIMError(wrt, req, http.StatusBadRequest, 20000, imReasonMalformedRequest, "malformed request", nil) default: @@ -4764,6 +6276,12 @@ func topicFromMediaPath(path string) string { return topicFromPathWithSuffix(path, prefix, suffix) } +func topicFromCallsPath(path string) string { + const prefix = "/v1/im/topics/" + const suffix = "/calls" + return topicFromPathWithSuffix(path, prefix, suffix) +} + func topicFromPathWithSuffix(path, prefix, suffix string) string { idx := strings.Index(path, prefix) if idx < 0 || !strings.HasSuffix(path, suffix) { @@ -4854,6 +6372,71 @@ func userResourcePath(path string) (types.Uid, string, bool) { return types.ParseUserId(strings.TrimSpace(userID)), strings.Trim(tail, "/"), true } +func organizationDepartmentResourcePath(path string) (string, string, bool) { + const prefix = "/v1/im/organization/departments/" + idx := strings.Index(path, prefix) + if idx < 0 { + return "", "", false + } + rest := strings.TrimPrefix(path[idx:], prefix) + departmentID, tail, ok := strings.Cut(rest, "/") + if !ok || departmentID == "" || tail == "" { + return "", "", false + } + return strings.TrimSpace(departmentID), strings.Trim(tail, "/"), true +} + +func meetingResourcePath(path string) (string, string, bool) { + const prefix = "/v1/im/meetings/" + idx := strings.Index(path, prefix) + if idx < 0 { + return "", "", false + } + rest := strings.TrimPrefix(path[idx:], prefix) + meetingID, tail, ok := strings.Cut(rest, "/") + if meetingID == "" { + return "", "", false + } + if !ok { + return strings.TrimSpace(meetingID), "", true + } + return strings.TrimSpace(meetingID), strings.Trim(tail, "/"), true +} + +func dailyReportResourcePath(path string) (string, string, bool) { + const prefix = "/v1/im/reports/daily/" + idx := strings.Index(path, prefix) + if idx < 0 { + return "", "", false + } + rest := strings.TrimPrefix(path[idx:], prefix) + reportID, tail, ok := strings.Cut(rest, "/") + if reportID == "" { + return "", "", false + } + if !ok { + return strings.TrimSpace(reportID), "", true + } + return strings.TrimSpace(reportID), strings.Trim(tail, "/"), true +} + +func monthlyReportResourcePath(path string) (string, string, bool) { + const prefix = "/v1/im/reports/monthly/" + idx := strings.Index(path, prefix) + if idx < 0 { + return "", "", false + } + rest := strings.TrimPrefix(path[idx:], prefix) + reportID, tail, ok := strings.Cut(rest, "/") + if reportID == "" { + return "", "", false + } + if !ok { + return strings.TrimSpace(reportID), "", true + } + return strings.TrimSpace(reportID), strings.Trim(tail, "/"), true +} + func groupMemberProfilePath(tail string) (types.Uid, bool) { parts := strings.Split(strings.Trim(tail, "/"), "/") if len(parts) != 3 || parts[0] != "members" || parts[2] != "profile" { diff --git a/server/im_http_test.go b/server/im_http_test.go index 8534b04..18b5b3f 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -52,6 +52,341 @@ type testFavoritesStore struct { deleted []string } +type testCallsStore struct { + items []types.CallRecord + topic string +} + +type testWorkbenchStore struct { + modules []types.WorkbenchModule + version int +} + +type testOrganizationStore struct { + departments []types.OrganizationDepartment + members []types.OrganizationMember + searchItems []types.OrganizationSearchItem + parentID string + departmentID string + query string + resultTypes string +} + +type testMeetingsStore struct { + meeting *types.Meeting + attendeeIDs []types.Uid + created *types.Meeting + response string + responseVersion int +} + +type testDailyReportsStore struct { + report *types.DailyReport + saved bool + submitted bool + remindKeys string +} + +type testMonthlyReportsStore struct { + report *types.MonthlyReport + saved bool + submitted bool +} + +type testMailboxStore struct { + config *types.MailboxConfig + submission *types.MailboxSubmission + idempotencyKey string +} + +type testProfilesStore struct { + profile *types.UserProfile + privacy *types.UserPrivacySettings +} + +type testAppReleaseStore struct { + release *types.AppRelease +} + +type testDevicePushTokenStore struct { + token *types.DevicePushToken +} + +func (s *testWorkbenchStore) Get(_ types.TenantID, _ types.Uid) ([]types.WorkbenchModule, int, error) { + if s.version == 0 { + s.version = 1 + } + return s.modules, s.version, nil +} + +func (s *testOrganizationStore) ListDepartments(_ types.TenantID, _ types.Uid, parentID, _ string, _ int) ([]types.OrganizationDepartment, string, error) { + s.parentID = parentID + return s.departments, "", nil +} + +func (s *testOrganizationStore) ListMembers(_ types.TenantID, _ types.Uid, departmentID, _ string, _ int) ([]types.OrganizationMember, string, error) { + s.departmentID = departmentID + return s.members, "", nil +} + +func (s *testOrganizationStore) Search(_ types.TenantID, _ types.Uid, queryText, resultTypes, _ string, _ int) ([]types.OrganizationSearchItem, string, error) { + s.query = queryText + s.resultTypes = resultTypes + return s.searchItems, "", nil +} + +func (s *testMeetingsStore) Create(_ types.TenantID, uid types.Uid, meeting *types.Meeting, attendeeIDs []types.Uid) (*types.Meeting, error) { + s.created = meeting + s.attendeeIDs = attendeeIDs + now := time.Now().UTC().Round(time.Millisecond) + meeting.OrganizerID = uid + meeting.State = types.MeetingStateScheduled + meeting.Version = 1 + meeting.CreatedAt = now + meeting.UpdatedAt = now + meeting.Participants = []types.MeetingParticipant{ + {MeetingID: meeting.MeetingID, UserID: uid, Response: types.MeetingResponseAccepted, ResponseAt: &now}, + } + for _, attendeeID := range attendeeIDs { + meeting.Participants = append(meeting.Participants, types.MeetingParticipant{MeetingID: meeting.MeetingID, UserID: attendeeID, Response: types.MeetingResponsePending}) + } + s.meeting = meeting + return meeting, nil +} + +func (s *testMeetingsStore) Get(_ types.TenantID, _ types.Uid, _ string) (*types.Meeting, error) { + return s.meeting, nil +} + +func (s *testMeetingsStore) Update(_ types.TenantID, _ types.Uid, _ string, _ types.MeetingPatch, _ string) (*types.Meeting, error) { + return s.meeting, nil +} + +func (s *testMeetingsStore) Cancel(_ types.TenantID, _ types.Uid, _ string, _ string) (*types.Meeting, error) { + s.meeting.State = types.MeetingStateCanceled + return s.meeting, nil +} + +func (s *testMeetingsStore) Remind(_ types.TenantID, _ types.Uid, _ string, _ string) ([]types.MeetingParticipant, error) { + return s.meeting.Participants, nil +} + +func (s *testMeetingsStore) Respond(_ types.TenantID, uid types.Uid, _ string, response string, version int) (*types.Meeting, error) { + s.response = response + s.responseVersion = version + s.meeting.Version++ + for i := range s.meeting.Participants { + if s.meeting.Participants[i].UserID == uid { + s.meeting.Participants[i].Response = response + } + } + return s.meeting, nil +} + +func (s *testDailyReportsStore) List(_ types.TenantID, _ types.Uid, _, _, _, _, _ string, _ int) ([]types.DailyReport, string, error) { + if s.report == nil { + return nil, "", nil + } + return []types.DailyReport{*s.report}, "", nil +} + +func (s *testDailyReportsStore) CreateDraft(_ types.TenantID, uid types.Uid, reportID string, reportDate time.Time) (*types.DailyReport, error) { + now := time.Now().UTC().Round(time.Millisecond) + s.report = &types.DailyReport{ + ReportID: reportID, + AuthorID: uid, + ReportDate: reportDate, + Status: types.DailyReportStatusDraft, + ReadState: "unread", + Version: 1, + CreatedAt: now, + UpdatedAt: now, + } + return s.report, nil +} + +func (s *testDailyReportsStore) Get(_ types.TenantID, _ types.Uid, _ string) (*types.DailyReport, error) { + return s.report, nil +} + +func (s *testDailyReportsStore) SaveDraft(_ types.TenantID, _ types.Uid, _ string, content string, attachments []string, version int) (*types.DailyReport, error) { + s.saved = true + s.report.Content = content + s.report.Attachments = attachments + s.report.Version = version + 1 + return s.report, nil +} + +func (s *testDailyReportsStore) Submit(_ types.TenantID, _ types.Uid, _ string, version int) (*types.DailyReport, error) { + s.submitted = true + now := time.Now().UTC().Round(time.Millisecond) + s.report.Status = types.DailyReportStatusSubmitted + s.report.SubmittedAt = &now + s.report.Version = version + 1 + return s.report, nil +} + +func (s *testDailyReportsStore) Stats(_ types.TenantID, _ types.Uid, _ time.Time, _ string) (*types.DailyReportStats, error) { + return &types.DailyReportStats{ExpectedCount: 3, SubmittedCount: 1, MissingCount: 2}, nil +} + +func (s *testDailyReportsStore) Remind(_ types.TenantID, _ types.Uid, _ time.Time, userIDs []types.Uid, idempotencyKey string) ([]types.Uid, error) { + s.remindKeys = idempotencyKey + return userIDs, nil +} + +func (s *testMonthlyReportsStore) List(_ types.TenantID, _ types.Uid, _, _, _, _, _ string, _ int) ([]types.MonthlyReport, string, error) { + if s.report == nil { + return nil, "", nil + } + return []types.MonthlyReport{*s.report}, "", nil +} + +func (s *testMonthlyReportsStore) CreateDraft(_ types.TenantID, uid types.Uid, reportID, month string) (*types.MonthlyReport, error) { + now := time.Now().UTC().Round(time.Millisecond) + s.report = &types.MonthlyReport{ + ReportID: reportID, + AuthorID: uid, + Month: month, + Status: types.DailyReportStatusDraft, + ReadState: "unread", + Version: 1, + CreatedAt: now, + UpdatedAt: now, + } + return s.report, nil +} + +func (s *testMonthlyReportsStore) Get(_ types.TenantID, _ types.Uid, _ string) (*types.MonthlyReport, error) { + return s.report, nil +} + +func (s *testMonthlyReportsStore) SaveDraft(_ types.TenantID, _ types.Uid, _ string, workSummary, nextPlan string, attachments []string, version int) (*types.MonthlyReport, error) { + s.saved = true + s.report.WorkSummary = workSummary + s.report.NextPlan = nextPlan + s.report.Attachments = attachments + s.report.Version = version + 1 + return s.report, nil +} + +func (s *testMonthlyReportsStore) Submit(_ types.TenantID, _ types.Uid, _ string, version int) (*types.MonthlyReport, error) { + s.submitted = true + now := time.Now().UTC().Round(time.Millisecond) + s.report.Status = types.DailyReportStatusSubmitted + s.report.SubmittedAt = &now + s.report.Version = version + 1 + return s.report, nil +} + +func (s *testMonthlyReportsStore) Stats(_ types.TenantID, _ types.Uid, _ string, _ string) (*types.MonthlyReportStats, error) { + return &types.MonthlyReportStats{ExpectedCount: 4, SubmittedCount: 2, MissingCount: 2}, nil +} + +func (s *testMailboxStore) Config(_ types.TenantID, _ types.Uid) (*types.MailboxConfig, error) { + return s.config, nil +} + +func (s *testMailboxStore) Submit(_ types.TenantID, uid types.Uid, submission *types.MailboxSubmission, idempotencyKey string) (*types.MailboxSubmission, error) { + s.idempotencyKey = idempotencyKey + now := time.Now().UTC().Round(time.Millisecond) + submission.SenderID = uid + submission.Status = types.MailboxSubmissionStatusReceived + submission.SubmittedAt = now + submission.CreatedAt = now + submission.UpdatedAt = now + s.submission = submission + return submission, nil +} + +func (s *testProfilesStore) Get(_ types.TenantID, uid types.Uid) (*types.UserProfile, error) { + if s.profile == nil { + now := time.Now().UTC().Round(time.Millisecond) + s.profile = &types.UserProfile{UserID: uid, PublicAccount: uid.UserId(), Version: 1, CreatedAt: now, UpdatedAt: now} + } + return s.profile, nil +} + +func (s *testProfilesStore) Update(_ types.TenantID, uid types.Uid, patch types.UserProfilePatch) (*types.UserProfile, error) { + profile, _ := s.Get(0, uid) + if patch.Nickname != nil { + profile.Nickname = *patch.Nickname + } + if patch.Signature != nil { + profile.Signature = *patch.Signature + } + profile.Version = patch.Version + 1 + profile.UpdatedAt = time.Now().UTC().Round(time.Millisecond) + return profile, nil +} + +func (s *testProfilesStore) GetPrivacy(_ types.TenantID, uid types.Uid) (*types.UserPrivacySettings, error) { + if s.privacy == nil { + now := time.Now().UTC().Round(time.Millisecond) + s.privacy = &types.UserPrivacySettings{ + UserID: uid, AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": true, "group": true}, + ProfileVisibility: "friends", LastSeenVisibility: "friends", ReadReceipts: true, Version: 1, CreatedAt: now, UpdatedAt: now, + } + } + return s.privacy, nil +} + +func (s *testProfilesStore) UpdatePrivacy(_ types.TenantID, uid types.Uid, patch types.UserPrivacyPatch) (*types.UserPrivacySettings, error) { + privacy, _ := s.GetPrivacy(0, uid) + if patch.AddMeMethods != nil { + privacy.AddMeMethods = patch.AddMeMethods + } + if patch.ReadReceipts != nil { + privacy.ReadReceipts = *patch.ReadReceipts + } + privacy.Version = patch.Version + 1 + privacy.UpdatedAt = time.Now().UTC().Round(time.Millisecond) + return privacy, nil +} + +func (s *testAppReleaseStore) Latest(_ types.TenantID, platform, channel string) (*types.AppRelease, error) { + if s.release == nil { + now := time.Now().UTC().Round(time.Millisecond) + s.release = &types.AppRelease{ + Platform: platform, + Channel: channel, + LatestVersion: "1.0.1", + MinSupportedVersion: "1.0.0", + UpdateType: "optional", + ReleaseNotes: "修复已知问题。", + PublishedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + } + return s.release, nil +} + +func (s *testDevicePushTokenStore) Upsert(_ types.TenantID, token *types.DevicePushToken) (*types.DevicePushToken, error) { + now := time.Now().UTC().Round(time.Millisecond) + token.CreatedAt = now + token.UpdatedAt = now + s.token = token + return token, nil +} + +func (s *testCallsStore) Create(_ types.TenantID, _ *types.CallRecord) error { + return nil +} + +func (s *testCallsStore) Accept(_ types.TenantID, _ string, _ int, _ time.Time) error { + return nil +} + +func (s *testCallsStore) Finish(_ types.TenantID, _ string, _ int, _ string, _ int64, _ time.Time) error { + return nil +} + +func (s *testCallsStore) List(_ types.TenantID, _ types.Uid, topic, _ string, _ int) ([]types.CallRecord, string, error) { + s.topic = topic + return s.items, "", nil +} + func (s *testFavoritesStore) Create(_ types.TenantID, item *types.FavoriteItem) (*types.FavoriteItem, error) { s.created = item s.items = append(s.items, *item) @@ -222,10 +557,10 @@ func expectIMBearer(t *testing.T, ctrl *gomock.Controller, token string, tenantI oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) - mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) - tokenAuth.EXPECT().IsInitialized().Return(true) + mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).AnyTimes() + tokenAuth.EXPECT().IsInitialized().Return(true).AnyTimes() tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte(token)). - Return(&auth.Rec{TenantID: tenantID, Uid: uid, AuthLevel: auth.LevelAuth}, nil, nil) + Return(&auth.Rec{TenantID: tenantID, Uid: uid, AuthLevel: auth.LevelAuth}, nil, nil).AnyTimes() store.Store = mockStore t.Cleanup(func() { store.Store = oldStore }) return mockStore @@ -722,6 +1057,578 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) { } } +func TestServeIMWorkbench(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldWorkbench := store.Workbench + store.Workbench = &testWorkbenchStore{version: 3, modules: []types.WorkbenchModule{ + {Code: "organization", Name: "组织架构", Route: "/organization", Enabled: true, Permission: "enabled"}, + {Code: "external", Name: "外部链接", Route: "https://example.com", Enabled: true, Permission: "enabled"}, + }} + defer func() { store.Workbench = oldWorkbench }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/workbench", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMWorkbench(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["layout_version"].(float64) != 3 { + t.Fatalf("unexpected layout version: %#v", data) + } + items := data["modules"].([]any) + if len(items) != 1 { + t.Fatalf("expected external route to be filtered, got %#v", items) + } + item := items[0].(map[string]any) + if item["code"] != "organization" || item["route"] != "/organization" { + t.Fatalf("unexpected module: %#v", item) + } +} + +func TestServeIMOrganizationDepartments(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldOrganization := store.Organization + org := &testOrganizationStore{departments: []types.OrganizationDepartment{ + {DepartmentID: "dep-sales", Name: "销售部", ParentID: "root", ChildCount: 1, MemberCount: 12, Version: 2}, + }} + store.Organization = org + defer func() { store.Organization = oldOrganization }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/organization/departments?parent_id=root", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMOrganizationDepartments(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + items := data["items"].([]any) + if len(items) != 1 || org.parentID != "root" { + t.Fatalf("unexpected departments response: %#v parent=%q", items, org.parentID) + } + item := items[0].(map[string]any) + if item["department_id"] != "dep-sales" || item["name"] != "销售部" { + t.Fatalf("unexpected department payload: %#v", item) + } +} + +func TestServeIMOrganizationDepartmentMembers(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldOrganization := store.Organization + org := &testOrganizationStore{members: []types.OrganizationMember{ + {DepartmentID: "dep-sales", UserID: types.ParseUserId("usrAAAAAAAAAAA"), DisplayName: "张三", Position: "销售经理", Status: "active", Contactable: true, Version: 1}, + }} + store.Organization = org + defer func() { store.Organization = oldOrganization }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/organization/departments/dep-sales/members", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMOrganizationDepartmentResource(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + items := data["items"].([]any) + if len(items) != 1 || org.departmentID != "dep-sales" || data["department_id"] != "dep-sales" { + t.Fatalf("unexpected members response: %#v department=%q", data, org.departmentID) + } + item := items[0].(map[string]any) + if item["display_name"] != "张三" || item["position"] != "销售经理" || item["contactable"] != true { + t.Fatalf("unexpected member payload: %#v", item) + } +} + +func TestServeIMOrganizationSearch(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldOrganization := store.Organization + member := types.OrganizationMember{DepartmentID: "dep-sales", UserID: types.ParseUserId("usrAAAAAAAAAAA"), DisplayName: "张三", Status: "active", Contactable: true, Version: 1} + org := &testOrganizationStore{searchItems: []types.OrganizationSearchItem{ + {Type: "department", Department: &types.OrganizationDepartment{DepartmentID: "dep-sales", Name: "销售部", ParentID: "root", Version: 1}}, + {Type: "user", Member: &member}, + }} + store.Organization = org + defer func() { store.Organization = oldOrganization }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/organization/search?q=%E9%94%80%E5%94%AE&types=department,user", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMOrganizationSearch(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + items := data["items"].([]any) + if len(items) != 2 || org.query != "销售" || org.resultTypes != "department,user" { + t.Fatalf("unexpected search response: %#v query=%q types=%q", items, org.query, org.resultTypes) + } + if items[0].(map[string]any)["type"] != "department" || items[1].(map[string]any)["type"] != "user" { + t.Fatalf("unexpected search payload: %#v", items) + } +} + +func TestServeIMMeetingsCreate(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldMeetings := store.Meetings + meetings := &testMeetingsStore{} + store.Meetings = meetings + defer func() { store.Meetings = oldMeetings }() + + body := `{"title":"周会","start_at":"2026-08-14T09:00:00Z","end_at":"2026-08-14T10:00:00Z","location":"A101","attendee_user_ids":["` + types.Uid(43).UserId() + `"],"reminder_minutes":[10]}` + req := httptest.NewRequest(http.MethodPost, "/v1/im/meetings", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer token-1") + req.Header.Set("Idempotency-Key", "mtg-1") + rec := httptest.NewRecorder() + serveIMMeetings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if meetings.created == nil || meetings.created.IdempotencyKey != "mtg-1" || meetings.created.Title != "周会" { + t.Fatalf("unexpected created meeting: %#v", meetings.created) + } + if len(meetings.attendeeIDs) != 1 || data["state"] != types.MeetingStateScheduled || data["my_response"] != types.MeetingResponseAccepted { + t.Fatalf("unexpected meeting response: %#v attendees=%#v", data, meetings.attendeeIDs) + } +} + +func TestServeIMMeetingMyResponse(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + attendee := types.Uid(43) + expectIMBearer(t, ctrl, "token-1", 7, attendee) + oldMeetings := store.Meetings + now := time.Now().UTC().Round(time.Millisecond) + meetings := &testMeetingsStore{meeting: &types.Meeting{ + MeetingID: "mtg_test", + OrganizerID: 42, + Title: "周会", + StartAt: now.Add(time.Hour), + EndAt: now.Add(2 * time.Hour), + State: types.MeetingStateScheduled, + Version: 3, + CreatedAt: now, + UpdatedAt: now, + Participants: []types.MeetingParticipant{ + {MeetingID: "mtg_test", UserID: 42, Response: types.MeetingResponseAccepted}, + {MeetingID: "mtg_test", UserID: attendee, Response: types.MeetingResponsePending}, + }, + }} + store.Meetings = meetings + defer func() { store.Meetings = oldMeetings }() + + req := httptest.NewRequest(http.MethodPut, "/v1/im/meetings/mtg_test/my-response", strings.NewReader(`{"response":"accepted","version":3}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMMeetingResource(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if meetings.response != types.MeetingResponseAccepted || meetings.responseVersion != 3 || data["my_response"] != types.MeetingResponseAccepted { + t.Fatalf("unexpected response: data=%#v store=%#v", data, meetings) + } +} + +func TestServeIMDailyReportsCreateAndList(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldDailyReports := store.DailyReports + reports := &testDailyReportsStore{} + store.DailyReports = reports + defer func() { store.DailyReports = oldDailyReports }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/reports/daily", strings.NewReader(`{"report_date":"2026-08-14"}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMDailyReports(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if reports.report == nil || data["report_date"] != "2026-08-14" || data["status"] != types.DailyReportStatusDraft { + t.Fatalf("unexpected daily report response: %#v report=%#v", data, reports.report) + } +} + +func TestServeIMDailyReportDraftAndSubmit(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldDailyReports := store.DailyReports + now := time.Now().UTC().Round(time.Millisecond) + reports := &testDailyReportsStore{report: &types.DailyReport{ + ReportID: "dr_test", + AuthorID: 42, + ReportDate: time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC), + Status: types.DailyReportStatusDraft, + ReadState: "unread", + Version: 1, + CreatedAt: now, + UpdatedAt: now, + }} + store.DailyReports = reports + defer func() { store.DailyReports = oldDailyReports }() + + req := httptest.NewRequest(http.MethodPut, "/v1/im/reports/daily/dr_test/draft", strings.NewReader(`{"content":"今日完成接口","attachments":["file1"],"version":1}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMDailyReportResource(rec, req) + if rec.Code != http.StatusOK || !reports.saved { + t.Fatalf("draft status=%d saved=%v body=%s", rec.Code, reports.saved, rec.Body.String()) + } + + req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/daily/dr_test/submit", strings.NewReader(`{"version":2}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec = httptest.NewRecorder() + serveIMDailyReportResource(rec, req) + if rec.Code != http.StatusOK || !reports.submitted { + t.Fatalf("submit status=%d submitted=%v body=%s", rec.Code, reports.submitted, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["status"] != types.DailyReportStatusSubmitted || data["submitted_at"] == nil { + t.Fatalf("unexpected submit response: %#v", data) + } +} + +func TestServeIMDailyReportStatisticsAndReminders(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldDailyReports := store.DailyReports + reports := &testDailyReportsStore{} + store.DailyReports = reports + defer func() { store.DailyReports = oldDailyReports }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/daily/statistics?date=2026-08-14", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMDailyReportStatistics(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("stats status=%d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["expected_count"].(float64) != 3 || data["missing_count"].(float64) != 2 { + t.Fatalf("unexpected stats: %#v", data) + } + + req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/daily/reminders", strings.NewReader(`{"date":"2026-08-14","user_ids":["`+types.Uid(43).UserId()+`"]}`)) + req.Header.Set("Authorization", "Bearer token-1") + req.Header.Set("Idempotency-Key", "daily-remind-1") + rec = httptest.NewRecorder() + serveIMDailyReportReminders(rec, req) + if rec.Code != http.StatusOK || reports.remindKeys != "daily-remind-1" { + t.Fatalf("remind status=%d key=%q body=%s", rec.Code, reports.remindKeys, rec.Body.String()) + } +} + +func TestServeIMMonthlyReportsCreate(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldMonthlyReports := store.MonthlyReports + reports := &testMonthlyReportsStore{} + store.MonthlyReports = reports + defer func() { store.MonthlyReports = oldMonthlyReports }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/reports/monthly", strings.NewReader(`{"month":"2026-08"}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMMonthlyReports(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if reports.report == nil || data["month"] != "2026-08" || data["action"] != "edit" { + t.Fatalf("unexpected monthly report response: %#v report=%#v", data, reports.report) + } +} + +func TestServeIMMonthlyReportDraftAndSubmit(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldMonthlyReports := store.MonthlyReports + now := time.Now().UTC().Round(time.Millisecond) + reports := &testMonthlyReportsStore{report: &types.MonthlyReport{ + ReportID: "mr_test", + AuthorID: 42, + Month: "2026-08", + Status: types.DailyReportStatusDraft, + ReadState: "unread", + Version: 1, + CreatedAt: now, + UpdatedAt: now, + }} + store.MonthlyReports = reports + defer func() { store.MonthlyReports = oldMonthlyReports }() + + req := httptest.NewRequest(http.MethodPut, "/v1/im/reports/monthly/mr_test/draft", strings.NewReader(`{"work_summary":"本月总结","next_plan":"下月计划","attachments":["file1"],"version":1}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMMonthlyReportResource(rec, req) + if rec.Code != http.StatusOK || !reports.saved { + t.Fatalf("draft status=%d saved=%v body=%s", rec.Code, reports.saved, rec.Body.String()) + } + + req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/monthly/mr_test/submit", strings.NewReader(`{"version":2}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec = httptest.NewRecorder() + serveIMMonthlyReportResource(rec, req) + if rec.Code != http.StatusOK || !reports.submitted { + t.Fatalf("submit status=%d submitted=%v body=%s", rec.Code, reports.submitted, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["status"] != types.DailyReportStatusSubmitted || data["action"] != "view" { + t.Fatalf("unexpected submit response: %#v", data) + } +} + +func TestServeIMMonthlyReportStatistics(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldMonthlyReports := store.MonthlyReports + reports := &testMonthlyReportsStore{} + store.MonthlyReports = reports + defer func() { store.MonthlyReports = oldMonthlyReports }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/monthly/statistics?month=2026-08", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMMonthlyReportStatistics(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("stats status=%d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["expected_count"].(float64) != 4 || data["submitted_count"].(float64) != 2 { + t.Fatalf("unexpected stats: %#v", data) + } +} + +func TestServeIMMailboxConfig(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldMailbox := store.Mailbox + now := time.Now().UTC().Round(time.Millisecond) + mailbox := &testMailboxStore{config: &types.MailboxConfig{ + Enabled: true, + AllowAnonymous: true, + RequiredFields: []string{"subject", "content"}, + MaxLength: 5000, + AttachmentPolicy: map[string]any{"max_files": float64(5)}, + Notice: types.MailboxNotice{Title: "总经理信箱", Content: "请确认文案", UpdatedAt: now}, + UpdatedAt: now, + }} + store.Mailbox = mailbox + defer func() { store.Mailbox = oldMailbox }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/executive-mailbox/config", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMMailboxConfig(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["enabled"] != true || data["allow_anonymous"] != true { + t.Fatalf("unexpected mailbox config: %#v", data) + } + notice := data["notice"].(map[string]any) + if notice["version"] == "" || notice["title"] != "总经理信箱" { + t.Fatalf("unexpected notice: %#v", notice) + } +} + +func TestServeIMMailboxSubmission(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldMailbox := store.Mailbox + mailbox := &testMailboxStore{} + store.Mailbox = mailbox + defer func() { store.Mailbox = oldMailbox }() + + body := `{"subject":"建议","content":"希望优化流程","anonymous":true,"attachment_file_ids":["file1"],"notice_version":"v1"}` + req := httptest.NewRequest(http.MethodPost, "/v1/im/executive-mailbox/submissions", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer token-1") + req.Header.Set("Idempotency-Key", "mailbox-1") + rec := httptest.NewRecorder() + serveIMMailboxSubmissions(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if mailbox.submission == nil || mailbox.idempotencyKey != "mailbox-1" || data["status"] != types.MailboxSubmissionStatusReceived { + t.Fatalf("unexpected submission: data=%#v store=%#v", data, mailbox) + } +} + +func TestServeIMMeProfile(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldProfiles := store.Profiles + profiles := &testProfilesStore{} + store.Profiles = profiles + defer func() { store.Profiles = oldProfiles }() + + req := httptest.NewRequest(http.MethodPatch, "/v1/im/me/profile", strings.NewReader(`{"nickname":"新昵称","signature":"签名","version":1}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMMeProfile(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["nickname"] != "新昵称" || data["version"].(float64) != 2 { + t.Fatalf("unexpected profile: %#v", data) + } +} + +func TestServeIMMePrivacy(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldProfiles := store.Profiles + profiles := &testProfilesStore{} + store.Profiles = profiles + defer func() { store.Profiles = oldProfiles }() + + req := httptest.NewRequest(http.MethodPatch, "/v1/im/me/privacy", strings.NewReader(`{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"version":1}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMMePrivacy(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + methods := data["add_me_methods"].(map[string]any) + if methods["phone"] != false || data["read_receipts"] != false || data["version"].(float64) != 2 { + t.Fatalf("unexpected privacy: %#v", data) + } +} + +func TestServeIMAppReleaseLatest(t *testing.T) { + oldTenants := store.Tenants + oldReleases := store.AppReleases + store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "default", Name: "Default", State: types.TenantStateActive}} + store.AppReleases = &testAppReleaseStore{} + defer func() { + store.Tenants = oldTenants + store.AppReleases = oldReleases + }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/app/releases/latest?platform=ios¤t_version=1.0.0", nil) + rec := httptest.NewRecorder() + serveIMAppReleaseLatest(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["platform"] != "ios" || data["latest_version"] != "1.0.1" || data["channel"] != "stable" { + t.Fatalf("unexpected release payload: %#v", data) + } +} + +func TestServeIMYouthModeEnableDisable(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldSecurity := store.Security + security := &testSecurityStore{ + actionToken: &types.ActionToken{Action: "set_independent_password", Target: "youth_mode"}, + independentPassword: &types.IndependentPassword{PasswordHash: []byte("hash")}, + } + store.Security = security + defer func() { store.Security = oldSecurity }() + + expectIMBearer(t, ctrl, "token-enable", 7, 42) + req := httptest.NewRequest(http.MethodPost, "/v1/im/settings/youth-mode/enable", + bytes.NewBufferString(`{"policy_version":2,"action_token":"action.1"}`)) + req.Header.Set("Authorization", "Bearer token-enable") + rec := httptest.NewRecorder() + serveIMYouthModeEnable(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("enable status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["enabled"] != true || data["policy_version"].(float64) != 2 { + t.Fatalf("unexpected youth enable payload: %#v", data) + } + + security.actionToken = &types.ActionToken{Action: "close_youth_mode", Target: "youth_mode"} + expectIMBearer(t, ctrl, "token-disable", 7, 42) + req = httptest.NewRequest(http.MethodPost, "/v1/im/settings/youth-mode/disable", nil) + req.Header.Set("Authorization", "Bearer token-disable") + req.Header.Set("X-Action-Token", "action.2") + rec = httptest.NewRecorder() + serveIMYouthModeDisable(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("disable status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data = decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["enabled"] != false { + t.Fatalf("unexpected youth disable payload: %#v", data) + } +} + +func TestServeIMDevicePushToken(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldTokens := store.DevicePushTokens + tokenStore := &testDevicePushTokenStore{} + store.DevicePushTokens = tokenStore + defer func() { store.DevicePushTokens = oldTokens }() + + expectIMBearer(t, ctrl, "token-1", 7, 42) + req := httptest.NewRequest(http.MethodPut, "/v1/im/devices/device-1/push-token", + bytes.NewBufferString(`{"provider":"fcm","token":"push-token-1","app_version":"1.0.1","platform":"android"}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMDevicePushToken(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if tokenStore.token == nil || tokenStore.token.DeviceID != "device-1" || tokenStore.token.Token != "push-token-1" { + t.Fatalf("unexpected stored push token: %#v", tokenStore.token) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["device_id"] != "device-1" || data["provider"] != "fcm" { + t.Fatalf("unexpected response: %#v", data) + } +} + func TestServeIMConversationSearch(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -1614,6 +2521,71 @@ func TestServeIMMessageSearchContextAndMedia(t *testing.T) { } } +func TestServeIMTopicCalls(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldStore := store.Store + mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) + tokenAuth := mock_auth.NewMockAuthHandler(ctrl) + mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) + tokenAuth.EXPECT().IsInitialized().Return(true) + tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). + Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) + store.Store = mockStore + oldSubs := store.Subs + oldCalls := store.Calls + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + calls := &testCallsStore{items: []types.CallRecord{{ + ID: 9, + TenantID: 7, + CallID: "call-1", + Topic: "p2pTopic", + SeqID: 12, + CallerID: 42, + CalleeID: 43, + CallType: "video", + Status: types.CallStatusFinished, + DurationMS: 1200, + StartedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC), + CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 8, 13, 9, 1, 0, 0, time.UTC), + }}} + store.Subs = subs + store.Calls = calls + defer func() { + store.Store = oldStore + store.Subs = oldSubs + store.Calls = oldCalls + }() + subs.EXPECT().Get(types.TenantID(7), "p2pTopic", types.Uid(42), false).Return(&types.Subscription{ + TenantID: 7, User: types.Uid(42).String(), Topic: "p2pTopic", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, + }, nil) + + req := httptest.NewRequest(http.MethodGet, "/v1/im/topics/p2pTopic/calls", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMTopicMessageAction(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("calls status: got %d body=%s", rec.Code, rec.Body.String()) + } + if calls.topic != "p2pTopic" { + t.Fatalf("calls topic: got %q", calls.topic) + } + var resp imHTTPResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + data := resp.Data.(map[string]any) + items := data["items"].([]any) + if len(items) != 1 { + t.Fatalf("items: got %#v", items) + } + item := items[0].(map[string]any) + if item["call_id"] != "call-1" || item["call_type"] != "video" || item["direction"] != "outgoing" { + t.Fatalf("unexpected call item: %#v", item) + } +} + func TestServeIMMeQRCodesAndResolve(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/server/main.go b/server/main.go index 91d5dae..9221504 100644 --- a/server/main.go +++ b/server/main.go @@ -788,13 +788,37 @@ func main() { mux.HandleFunc(config.ApiPath+"v1/im/security/realname", serveIMRealName) mux.HandleFunc(config.ApiPath+"v1/im/security/realname/submissions", serveIMRealNameSubmissions) mux.HandleFunc(config.ApiPath+"v1/im/bootstrap", serveIMBootstrap) + mux.HandleFunc(config.ApiPath+"v1/im/workbench", serveIMWorkbench) + mux.HandleFunc(config.ApiPath+"v1/im/organization/departments", serveIMOrganizationDepartments) + mux.HandleFunc(config.ApiPath+"v1/im/organization/departments/", serveIMOrganizationDepartmentResource) + mux.HandleFunc(config.ApiPath+"v1/im/organization/search", serveIMOrganizationSearch) + mux.HandleFunc(config.ApiPath+"v1/im/meetings", serveIMMeetings) + mux.HandleFunc(config.ApiPath+"v1/im/meetings/", serveIMMeetingResource) + mux.HandleFunc(config.ApiPath+"v1/im/reports/daily/statistics", serveIMDailyReportStatistics) + mux.HandleFunc(config.ApiPath+"v1/im/reports/daily/reminders", serveIMDailyReportReminders) + mux.HandleFunc(config.ApiPath+"v1/im/reports/daily", serveIMDailyReports) + mux.HandleFunc(config.ApiPath+"v1/im/reports/daily/", serveIMDailyReportResource) + mux.HandleFunc(config.ApiPath+"v1/im/reports/monthly/statistics", serveIMMonthlyReportStatistics) + mux.HandleFunc(config.ApiPath+"v1/im/reports/monthly", serveIMMonthlyReports) + mux.HandleFunc(config.ApiPath+"v1/im/reports/monthly/", serveIMMonthlyReportResource) + mux.HandleFunc(config.ApiPath+"v1/im/executive-mailbox/config", serveIMMailboxConfig) + mux.HandleFunc(config.ApiPath+"v1/im/executive-mailbox/submissions", serveIMMailboxSubmissions) + mux.HandleFunc(config.ApiPath+"v1/im/app/releases/latest", serveIMAppReleaseLatest) + mux.HandleFunc(config.ApiPath+"v1/im/me", serveIMMe) + mux.HandleFunc(config.ApiPath+"v1/im/me/profile", serveIMMeProfile) + mux.HandleFunc(config.ApiPath+"v1/im/me/privacy", serveIMMePrivacy) + mux.HandleFunc(config.ApiPath+"v1/im/settings/youth-mode/enable", serveIMYouthModeEnable) + mux.HandleFunc(config.ApiPath+"v1/im/settings/youth-mode/disable", serveIMYouthModeDisable) + mux.HandleFunc(config.ApiPath+"v1/im/settings/youth-mode", serveIMYouthMode) mux.HandleFunc(config.ApiPath+"v1/im/settings/independent-password", serveIMIndependentPassword) mux.HandleFunc(config.ApiPath+"v1/im/settings/independent-password/verify", serveIMIndependentPasswordVerify) + mux.HandleFunc(config.ApiPath+"v1/im/devices/", serveIMDevicePushToken) mux.HandleFunc(config.ApiPath+"v1/im/search/conversations", serveIMConversationSearch) mux.HandleFunc(config.ApiPath+"v1/im/system-notifications", serveIMSystemNotifications) mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead) mux.HandleFunc(config.ApiPath+"v1/im/favorites", serveIMFavorites) mux.HandleFunc(config.ApiPath+"v1/im/favorites/", serveIMFavoriteResource) + mux.HandleFunc(config.ApiPath+"v1/im/calls", serveIMCalls) mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction) mux.HandleFunc(config.ApiPath+"v1/im/conversations/", serveIMConversationContext) mux.HandleFunc(config.ApiPath+"v1/im/groups", serveIMGroups) diff --git a/server/store/store.go b/server/store/store.go index 4b6d53d..a71fbd3 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -1018,6 +1018,7 @@ type CallsPersistenceInterface interface { Create(tenantID types.TenantID, record *types.CallRecord) error Accept(tenantID types.TenantID, callID string, seq int, acceptedAt time.Time) error Finish(tenantID types.TenantID, callID string, seq int, status string, durationMS int64, finishedAt time.Time) error + List(tenantID types.TenantID, uid types.Uid, topic, cursor string, limit int) ([]types.CallRecord, string, error) } type callsMapper struct{} @@ -1064,6 +1065,521 @@ func (callsMapper) Finish(tenantID types.TenantID, callID string, seq int, statu return tenantAdp.TenantCallFinish(tenantID, callID, seq, status, durationMS, finishedAt) } +func (callsMapper) List(tenantID types.TenantID, uid types.Uid, topic, cursor string, limit int) ([]types.CallRecord, string, error) { + if uid.IsZero() { + return nil, "", types.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, "", err + } + return tenantAdp.TenantCallList(tenantID, uid, strings.TrimSpace(topic), strings.TrimSpace(cursor), limit) +} + +type WorkbenchPersistenceInterface interface { + Get(tenantID types.TenantID, uid types.Uid) ([]types.WorkbenchModule, int, error) +} + +type workbenchMapper struct{} + +var Workbench WorkbenchPersistenceInterface + +func (workbenchMapper) Get(tenantID types.TenantID, uid types.Uid) ([]types.WorkbenchModule, int, error) { + if uid.IsZero() { + return nil, 0, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, 0, err + } + return tenantAdp.TenantWorkbenchModules(tenantID, uid) +} + +type OrganizationPersistenceInterface interface { + ListDepartments(tenantID types.TenantID, uid types.Uid, parentID, cursor string, limit int) ([]types.OrganizationDepartment, string, error) + ListMembers(tenantID types.TenantID, uid types.Uid, departmentID, cursor string, limit int) ([]types.OrganizationMember, string, error) + Search(tenantID types.TenantID, uid types.Uid, queryText, resultTypes, cursor string, limit int) ([]types.OrganizationSearchItem, string, error) +} + +type organizationMapper struct{} + +var Organization OrganizationPersistenceInterface + +func (organizationMapper) ListDepartments(tenantID types.TenantID, uid types.Uid, parentID, cursor string, limit int) ([]types.OrganizationDepartment, string, error) { + if uid.IsZero() { + return nil, "", types.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, "", err + } + return tenantAdp.TenantOrganizationDepartments(tenantID, uid, strings.TrimSpace(parentID), strings.TrimSpace(cursor), limit) +} + +func (organizationMapper) ListMembers(tenantID types.TenantID, uid types.Uid, departmentID, cursor string, limit int) ([]types.OrganizationMember, string, error) { + if uid.IsZero() || strings.TrimSpace(departmentID) == "" { + return nil, "", types.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, "", err + } + return tenantAdp.TenantOrganizationMembers(tenantID, uid, strings.TrimSpace(departmentID), strings.TrimSpace(cursor), limit) +} + +func (organizationMapper) Search(tenantID types.TenantID, uid types.Uid, queryText, resultTypes, cursor string, limit int) ([]types.OrganizationSearchItem, string, error) { + if uid.IsZero() || strings.TrimSpace(queryText) == "" { + return nil, "", types.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, "", err + } + return tenantAdp.TenantOrganizationSearch(tenantID, uid, strings.TrimSpace(queryText), strings.TrimSpace(resultTypes), strings.TrimSpace(cursor), limit) +} + +type MeetingsPersistenceInterface interface { + Create(tenantID types.TenantID, uid types.Uid, meeting *types.Meeting, attendeeIDs []types.Uid) (*types.Meeting, error) + Get(tenantID types.TenantID, uid types.Uid, meetingID string) (*types.Meeting, error) + Update(tenantID types.TenantID, uid types.Uid, meetingID string, patch types.MeetingPatch, idempotencyKey string) (*types.Meeting, error) + Cancel(tenantID types.TenantID, uid types.Uid, meetingID, idempotencyKey string) (*types.Meeting, error) + Remind(tenantID types.TenantID, uid types.Uid, meetingID, idempotencyKey string) ([]types.MeetingParticipant, error) + Respond(tenantID types.TenantID, uid types.Uid, meetingID, response string, version int) (*types.Meeting, error) +} + +type meetingsMapper struct{} + +var Meetings MeetingsPersistenceInterface + +func (meetingsMapper) Create(tenantID types.TenantID, uid types.Uid, meeting *types.Meeting, attendeeIDs []types.Uid) (*types.Meeting, error) { + if uid.IsZero() || meeting == nil || !validMeetingForCreate(meeting) { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMeetingCreate(tenantID, uid, meeting, attendeeIDs) +} + +func (meetingsMapper) Get(tenantID types.TenantID, uid types.Uid, meetingID string) (*types.Meeting, error) { + if uid.IsZero() || strings.TrimSpace(meetingID) == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMeetingGet(tenantID, uid, strings.TrimSpace(meetingID)) +} + +func (meetingsMapper) Update(tenantID types.TenantID, uid types.Uid, meetingID string, patch types.MeetingPatch, idempotencyKey string) (*types.Meeting, error) { + if uid.IsZero() || strings.TrimSpace(meetingID) == "" || patch.Version <= 0 || !validMeetingPatch(patch) { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMeetingUpdate(tenantID, uid, strings.TrimSpace(meetingID), patch, strings.TrimSpace(idempotencyKey)) +} + +func (meetingsMapper) Cancel(tenantID types.TenantID, uid types.Uid, meetingID, idempotencyKey string) (*types.Meeting, error) { + if uid.IsZero() || strings.TrimSpace(meetingID) == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMeetingCancel(tenantID, uid, strings.TrimSpace(meetingID), strings.TrimSpace(idempotencyKey)) +} + +func (meetingsMapper) Remind(tenantID types.TenantID, uid types.Uid, meetingID, idempotencyKey string) ([]types.MeetingParticipant, error) { + if uid.IsZero() || strings.TrimSpace(meetingID) == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMeetingRemind(tenantID, uid, strings.TrimSpace(meetingID), strings.TrimSpace(idempotencyKey)) +} + +func (meetingsMapper) Respond(tenantID types.TenantID, uid types.Uid, meetingID, response string, version int) (*types.Meeting, error) { + if uid.IsZero() || strings.TrimSpace(meetingID) == "" || !validMeetingResponse(response) || version <= 0 { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMeetingRespond(tenantID, uid, strings.TrimSpace(meetingID), strings.TrimSpace(response), version) +} + +func validMeetingForCreate(meeting *types.Meeting) bool { + return strings.TrimSpace(meeting.MeetingID) != "" && + strings.TrimSpace(meeting.Title) != "" && + !meeting.StartAt.IsZero() && + !meeting.EndAt.IsZero() && + meeting.EndAt.After(meeting.StartAt) +} + +func validMeetingPatch(patch types.MeetingPatch) bool { + if patch.Title == nil && patch.StartAt == nil && patch.EndAt == nil && patch.Location == nil && patch.ReminderMinutes == nil { + return false + } + if patch.Title != nil && strings.TrimSpace(*patch.Title) == "" { + return false + } + if patch.StartAt != nil && patch.EndAt != nil && !patch.EndAt.After(*patch.StartAt) { + return false + } + return true +} + +func validMeetingResponse(response string) bool { + switch strings.TrimSpace(response) { + case types.MeetingResponseAccepted, types.MeetingResponseDeclined, types.MeetingResponseTentative: + return true + default: + return false + } +} + +type DailyReportsPersistenceInterface interface { + List(tenantID types.TenantID, uid types.Uid, scope, from, to, status, cursor string, limit int) ([]types.DailyReport, string, error) + CreateDraft(tenantID types.TenantID, uid types.Uid, reportID string, reportDate time.Time) (*types.DailyReport, error) + Get(tenantID types.TenantID, uid types.Uid, reportID string) (*types.DailyReport, error) + SaveDraft(tenantID types.TenantID, uid types.Uid, reportID, content string, attachments []string, version int) (*types.DailyReport, error) + Submit(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.DailyReport, error) + Stats(tenantID types.TenantID, uid types.Uid, date time.Time, departmentID string) (*types.DailyReportStats, error) + Remind(tenantID types.TenantID, uid types.Uid, date time.Time, userIDs []types.Uid, idempotencyKey string) ([]types.Uid, error) +} + +type dailyReportsMapper struct{} + +var DailyReports DailyReportsPersistenceInterface + +func (dailyReportsMapper) List(tenantID types.TenantID, uid types.Uid, scope, from, to, status, cursor string, limit int) ([]types.DailyReport, string, error) { + if uid.IsZero() { + return nil, "", types.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, "", err + } + return tenantAdp.TenantDailyReportList(tenantID, uid, strings.TrimSpace(scope), strings.TrimSpace(from), strings.TrimSpace(to), strings.TrimSpace(status), strings.TrimSpace(cursor), limit) +} + +func (dailyReportsMapper) CreateDraft(tenantID types.TenantID, uid types.Uid, reportID string, reportDate time.Time) (*types.DailyReport, error) { + if uid.IsZero() || strings.TrimSpace(reportID) == "" || reportDate.IsZero() { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantDailyReportCreateDraft(tenantID, uid, strings.TrimSpace(reportID), reportDate) +} + +func (dailyReportsMapper) Get(tenantID types.TenantID, uid types.Uid, reportID string) (*types.DailyReport, error) { + if uid.IsZero() || strings.TrimSpace(reportID) == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantDailyReportGet(tenantID, uid, strings.TrimSpace(reportID)) +} + +func (dailyReportsMapper) SaveDraft(tenantID types.TenantID, uid types.Uid, reportID, content string, attachments []string, version int) (*types.DailyReport, error) { + if uid.IsZero() || strings.TrimSpace(reportID) == "" || version <= 0 { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantDailyReportSaveDraft(tenantID, uid, strings.TrimSpace(reportID), content, attachments, version) +} + +func (dailyReportsMapper) Submit(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.DailyReport, error) { + if uid.IsZero() || strings.TrimSpace(reportID) == "" || version <= 0 { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantDailyReportSubmit(tenantID, uid, strings.TrimSpace(reportID), version) +} + +func (dailyReportsMapper) Stats(tenantID types.TenantID, uid types.Uid, date time.Time, departmentID string) (*types.DailyReportStats, error) { + if uid.IsZero() || date.IsZero() { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantDailyReportStats(tenantID, uid, date, strings.TrimSpace(departmentID)) +} + +func (dailyReportsMapper) Remind(tenantID types.TenantID, uid types.Uid, date time.Time, userIDs []types.Uid, idempotencyKey string) ([]types.Uid, error) { + if uid.IsZero() || date.IsZero() || strings.TrimSpace(idempotencyKey) == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantDailyReportRemind(tenantID, uid, date, userIDs, strings.TrimSpace(idempotencyKey)) +} + +type MonthlyReportsPersistenceInterface interface { + List(tenantID types.TenantID, uid types.Uid, scope, fromMonth, toMonth, status, cursor string, limit int) ([]types.MonthlyReport, string, error) + CreateDraft(tenantID types.TenantID, uid types.Uid, reportID, month string) (*types.MonthlyReport, error) + Get(tenantID types.TenantID, uid types.Uid, reportID string) (*types.MonthlyReport, error) + SaveDraft(tenantID types.TenantID, uid types.Uid, reportID, workSummary, nextPlan string, attachments []string, version int) (*types.MonthlyReport, error) + Submit(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.MonthlyReport, error) + Stats(tenantID types.TenantID, uid types.Uid, month, departmentID string) (*types.MonthlyReportStats, error) +} + +type monthlyReportsMapper struct{} + +var MonthlyReports MonthlyReportsPersistenceInterface + +func (monthlyReportsMapper) List(tenantID types.TenantID, uid types.Uid, scope, fromMonth, toMonth, status, cursor string, limit int) ([]types.MonthlyReport, string, error) { + if uid.IsZero() { + return nil, "", types.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, "", err + } + return tenantAdp.TenantMonthlyReportList(tenantID, uid, strings.TrimSpace(scope), strings.TrimSpace(fromMonth), strings.TrimSpace(toMonth), strings.TrimSpace(status), strings.TrimSpace(cursor), limit) +} + +func (monthlyReportsMapper) CreateDraft(tenantID types.TenantID, uid types.Uid, reportID, month string) (*types.MonthlyReport, error) { + if uid.IsZero() || strings.TrimSpace(reportID) == "" || strings.TrimSpace(month) == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMonthlyReportCreateDraft(tenantID, uid, strings.TrimSpace(reportID), strings.TrimSpace(month)) +} + +func (monthlyReportsMapper) Get(tenantID types.TenantID, uid types.Uid, reportID string) (*types.MonthlyReport, error) { + if uid.IsZero() || strings.TrimSpace(reportID) == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMonthlyReportGet(tenantID, uid, strings.TrimSpace(reportID)) +} + +func (monthlyReportsMapper) SaveDraft(tenantID types.TenantID, uid types.Uid, reportID, workSummary, nextPlan string, attachments []string, version int) (*types.MonthlyReport, error) { + if uid.IsZero() || strings.TrimSpace(reportID) == "" || version <= 0 { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMonthlyReportSaveDraft(tenantID, uid, strings.TrimSpace(reportID), workSummary, nextPlan, attachments, version) +} + +func (monthlyReportsMapper) Submit(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.MonthlyReport, error) { + if uid.IsZero() || strings.TrimSpace(reportID) == "" || version <= 0 { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMonthlyReportSubmit(tenantID, uid, strings.TrimSpace(reportID), version) +} + +func (monthlyReportsMapper) Stats(tenantID types.TenantID, uid types.Uid, month, departmentID string) (*types.MonthlyReportStats, error) { + if uid.IsZero() || strings.TrimSpace(month) == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMonthlyReportStats(tenantID, uid, strings.TrimSpace(month), strings.TrimSpace(departmentID)) +} + +type MailboxPersistenceInterface interface { + Config(tenantID types.TenantID, uid types.Uid) (*types.MailboxConfig, error) + Submit(tenantID types.TenantID, uid types.Uid, submission *types.MailboxSubmission, idempotencyKey string) (*types.MailboxSubmission, error) +} + +type mailboxMapper struct{} + +var Mailbox MailboxPersistenceInterface + +func (mailboxMapper) Config(tenantID types.TenantID, uid types.Uid) (*types.MailboxConfig, error) { + if uid.IsZero() { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMailboxConfigGet(tenantID, uid) +} + +func (mailboxMapper) Submit(tenantID types.TenantID, uid types.Uid, submission *types.MailboxSubmission, idempotencyKey string) (*types.MailboxSubmission, error) { + if uid.IsZero() || submission == nil || strings.TrimSpace(submission.SubmissionID) == "" || + strings.TrimSpace(submission.Subject) == "" || strings.TrimSpace(submission.Content) == "" || + strings.TrimSpace(idempotencyKey) == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMailboxSubmissionCreate(tenantID, uid, submission, strings.TrimSpace(idempotencyKey)) +} + +type ProfilePersistenceInterface interface { + Get(tenantID types.TenantID, uid types.Uid) (*types.UserProfile, error) + Update(tenantID types.TenantID, uid types.Uid, patch types.UserProfilePatch) (*types.UserProfile, error) + GetPrivacy(tenantID types.TenantID, uid types.Uid) (*types.UserPrivacySettings, error) + UpdatePrivacy(tenantID types.TenantID, uid types.Uid, patch types.UserPrivacyPatch) (*types.UserPrivacySettings, error) +} + +type profileMapper struct{} + +var Profiles ProfilePersistenceInterface + +type AppReleasePersistenceInterface interface { + Latest(tenantID types.TenantID, platform, channel string) (*types.AppRelease, error) +} + +type appReleaseMapper struct{} + +var AppReleases AppReleasePersistenceInterface + +func (appReleaseMapper) Latest(tenantID types.TenantID, platform, channel string) (*types.AppRelease, error) { + platform = strings.ToLower(strings.TrimSpace(platform)) + channel = strings.ToLower(strings.TrimSpace(channel)) + if platform == "" || channel == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantAppReleaseLatest(tenantID, platform, channel) +} + +type DevicePushTokenPersistenceInterface interface { + Upsert(tenantID types.TenantID, token *types.DevicePushToken) (*types.DevicePushToken, error) +} + +type devicePushTokenMapper struct{} + +var DevicePushTokens DevicePushTokenPersistenceInterface + +func (devicePushTokenMapper) Upsert(tenantID types.TenantID, token *types.DevicePushToken) (*types.DevicePushToken, error) { + if token == nil || token.UserID.IsZero() || strings.TrimSpace(token.DeviceID) == "" || + strings.TrimSpace(token.Provider) == "" || strings.TrimSpace(token.Token) == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantDevicePushTokenUpsert(tenantID, token) +} + +func (profileMapper) Get(tenantID types.TenantID, uid types.Uid) (*types.UserProfile, error) { + if uid.IsZero() { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantUserProfileGet(tenantID, uid) +} + +func (profileMapper) Update(tenantID types.TenantID, uid types.Uid, patch types.UserProfilePatch) (*types.UserProfile, error) { + if uid.IsZero() || patch.Version <= 0 || !validUserProfilePatch(patch) { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantUserProfileUpdate(tenantID, uid, patch) +} + +func (profileMapper) GetPrivacy(tenantID types.TenantID, uid types.Uid) (*types.UserPrivacySettings, error) { + if uid.IsZero() { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantUserPrivacyGet(tenantID, uid) +} + +func (profileMapper) UpdatePrivacy(tenantID types.TenantID, uid types.Uid, patch types.UserPrivacyPatch) (*types.UserPrivacySettings, error) { + if uid.IsZero() || patch.Version <= 0 { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantUserPrivacyUpdate(tenantID, uid, patch) +} + +func validUserProfilePatch(patch types.UserProfilePatch) bool { + if patch.AvatarFileID == nil && patch.Nickname == nil && patch.BirthDate == nil && patch.Signature == nil && patch.Gender == nil && patch.Region == nil { + return false + } + if patch.Nickname != nil { + name := strings.TrimSpace(*patch.Nickname) + if len([]rune(name)) == 0 || len([]rune(name)) > 32 { + return false + } + } + if patch.Signature != nil && len([]rune(*patch.Signature)) > 120 { + return false + } + return true +} + type RelationsPersistenceInterface interface { CreateFriendRequest(tenantID types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error) ListFriendRequests(tenantID types.TenantID, uid types.Uid, direction, status, cursor string, limit int) ([]types.FriendRequestListItem, string, error) @@ -2015,6 +2531,8 @@ type UserSecurityPersistenceInterface interface { GetIndependentPassword(tenantID types.TenantID, uid types.Uid, purpose string) (*types.IndependentPassword, error) UpsertIndependentPassword(tenantID types.TenantID, pass *types.IndependentPassword) error UpdateIndependentPasswordAttempts(tenantID types.TenantID, uid types.Uid, purpose string, attempts int, lockedUntil *time.Time, updatedAt time.Time) error + GetYouthMode(tenantID types.TenantID, uid types.Uid) (*types.YouthModeSettings, error) + SetYouthMode(tenantID types.TenantID, uid types.Uid, enabled bool, policyVersion int, now time.Time) (*types.YouthModeSettings, error) CreateSystemNotification(tenantID types.TenantID, notification *types.SystemNotification) error ListSystemNotifications(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.SystemNotification, string, error) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error @@ -2106,6 +2624,28 @@ func (userSecurityMapper) UpdateIndependentPasswordAttempts(tenantID types.Tenan return tenantAdp.TenantIndependentPasswordUpdateAttempts(tenantID, uid, purpose, attempts, lockedUntil, updatedAt) } +func (userSecurityMapper) GetYouthMode(tenantID types.TenantID, uid types.Uid) (*types.YouthModeSettings, error) { + if uid.IsZero() { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantYouthModeGet(tenantID, uid) +} + +func (userSecurityMapper) SetYouthMode(tenantID types.TenantID, uid types.Uid, enabled bool, policyVersion int, now time.Time) (*types.YouthModeSettings, error) { + if uid.IsZero() || policyVersion <= 0 { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantYouthModeSet(tenantID, uid, enabled, policyVersion, now) +} + func (userSecurityMapper) CreateSystemNotification(tenantID types.TenantID, notification *types.SystemNotification) error { if notification == nil || notification.TenantID != tenantID { return types.ErrMalformed @@ -2232,6 +2772,15 @@ func init() { Subs = subsMapper{} Messages = messagesMapper{} Calls = callsMapper{} + Workbench = workbenchMapper{} + Organization = organizationMapper{} + Meetings = meetingsMapper{} + DailyReports = dailyReportsMapper{} + MonthlyReports = monthlyReportsMapper{} + Mailbox = mailboxMapper{} + Profiles = profileMapper{} + AppReleases = appReleaseMapper{} + DevicePushTokens = devicePushTokenMapper{} Devices = deviceMapper{} Files = fileMapper{} PCache = pcacheMapper{} diff --git a/server/store/types/calls.go b/server/store/types/calls.go index 4cfc657..0cfd94d 100644 --- a/server/store/types/calls.go +++ b/server/store/types/calls.go @@ -12,6 +12,7 @@ const ( ) type CallRecord struct { + ID int64 `db:"id"` TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"` CallID string `db:"call_id"` Topic string diff --git a/server/store/types/mailbox.go b/server/store/types/mailbox.go new file mode 100644 index 0000000..5c23118 --- /dev/null +++ b/server/store/types/mailbox.go @@ -0,0 +1,36 @@ +package types + +import "time" + +const MailboxSubmissionStatusReceived = "received" + +type MailboxNotice struct { + Title string + Content string + UpdatedAt time.Time +} + +type MailboxConfig struct { + Enabled bool + AllowAnonymous bool + RequiredFields []string + MaxLength int + AttachmentPolicy map[string]any + Notice MailboxNotice + UpdatedAt time.Time +} + +type MailboxSubmission struct { + ID int64 + SubmissionID string + SenderID Uid + Subject string + Content string + Anonymous bool + AttachmentFileIDs []string + NoticeVersion string + Status string + SubmittedAt time.Time + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/server/store/types/meetings.go b/server/store/types/meetings.go new file mode 100644 index 0000000..cbd28ff --- /dev/null +++ b/server/store/types/meetings.go @@ -0,0 +1,50 @@ +package types + +import "time" + +const ( + MeetingStateScheduled = "scheduled" + MeetingStateCanceled = "canceled" + + MeetingResponsePending = "pending" + MeetingResponseAccepted = "accepted" + MeetingResponseDeclined = "declined" + MeetingResponseTentative = "tentative" +) + +type Meeting struct { + ID int64 + MeetingID string + OrganizerID Uid + Title string + StartAt time.Time + EndAt time.Time + Location string + State string + ReminderMinutes []int + IdempotencyKey string + Version int + CanceledAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time + Participants []MeetingParticipant +} + +type MeetingParticipant struct { + ID int64 + MeetingID string + UserID Uid + Response string + ResponseAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type MeetingPatch struct { + Title *string + StartAt *time.Time + EndAt *time.Time + Location *string + ReminderMinutes []int + Version int +} diff --git a/server/store/types/organization.go b/server/store/types/organization.go new file mode 100644 index 0000000..290615b --- /dev/null +++ b/server/store/types/organization.go @@ -0,0 +1,40 @@ +package types + +import "time" + +type OrganizationDepartment struct { + ID int64 `db:"id"` + DepartmentID string `db:"department_id"` + Name string `db:"name"` + ParentID string `db:"parent_id"` + SortOrder int `db:"sort_order"` + ChildCount int `db:"child_count"` + MemberCount int `db:"member_count"` + Version int `db:"version"` + State string `db:"state"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type OrganizationMember struct { + ID int64 `db:"id"` + DepartmentID string `db:"department_id"` + UserID Uid `db:"-"` + DisplayName string `db:"display_name"` + Position string `db:"position"` + AvatarFileID string `db:"avatar_file_id"` + Mobile string `db:"mobile"` + Email string `db:"email"` + Status string `db:"employment_state"` + Contactable bool `db:"contactable"` + SortOrder int `db:"sort_order"` + Version int `db:"version"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type OrganizationSearchItem struct { + Type string + Department *OrganizationDepartment + Member *OrganizationMember +} diff --git a/server/store/types/profile.go b/server/store/types/profile.go new file mode 100644 index 0000000..618e3a0 --- /dev/null +++ b/server/store/types/profile.go @@ -0,0 +1,86 @@ +package types + +import "time" + +type UserProfile struct { + UserID Uid + AvatarFileID string + Nickname string + BirthDate string + Signature string + Gender string + Region string + PublicAccount string + Version int + CreatedAt time.Time + UpdatedAt time.Time +} + +type UserProfilePatch struct { + AvatarFileID *string + Nickname *string + BirthDate *string + Signature *string + Gender *string + Region *string + Version int +} + +type UserPrivacySettings struct { + UserID Uid + AddMeMethods map[string]bool + ProfileVisibility string + LastSeenVisibility string + ReadReceipts bool + Version int + CreatedAt time.Time + UpdatedAt time.Time +} + +type UserPrivacyPatch struct { + AddMeMethods map[string]bool + ProfileVisibility *string + LastSeenVisibility *string + ReadReceipts *bool + Version int +} + +type AppRelease struct { + ID int64 + Platform string + Channel string + LatestVersion string + MinSupportedVersion string + UpdateType string + ReleaseNotes string + StoreURL string + PackageURL string + Checksum string + RemindAfterSeconds int + PublishedAt time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type YouthModeSettings struct { + UserID Uid + Enabled bool + PolicyVersion int + Restrictions []string + PasswordSet bool + CanEnable bool + EnabledAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type DevicePushToken struct { + UserID Uid + DeviceID string + Provider string + Token string + AppVersion string + Platform string + UpdatedAt time.Time + CreatedAt time.Time +} diff --git a/server/store/types/reports.go b/server/store/types/reports.go new file mode 100644 index 0000000..8eb4db5 --- /dev/null +++ b/server/store/types/reports.go @@ -0,0 +1,51 @@ +package types + +import "time" + +const ( + DailyReportStatusDraft = "draft" + DailyReportStatusSubmitted = "submitted" +) + +type DailyReport struct { + ID int64 + ReportID string + AuthorID Uid + ReportDate time.Time + Content string + Attachments []string + Status string + ReadState string + Version int + SubmittedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type DailyReportStats struct { + ExpectedCount int + SubmittedCount int + MissingCount int +} + +type MonthlyReport struct { + ID int64 + ReportID string + AuthorID Uid + Month string + WorkSummary string + NextPlan string + Attachments []string + Status string + ReadState string + Version int + SubmittedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type MonthlyReportStats struct { + ExpectedCount int + SubmittedCount int + MissingCount int +} diff --git a/server/store/types/workbench.go b/server/store/types/workbench.go new file mode 100644 index 0000000..797f803 --- /dev/null +++ b/server/store/types/workbench.go @@ -0,0 +1,17 @@ +package types + +import "time" + +type WorkbenchModule struct { + ID int64 `db:"id"` + Code string `db:"code"` + Name string `db:"name"` + IconFileID string `db:"icon_file_id"` + Route string `db:"route"` + SortOrder int `db:"sort_order"` + UnreadKey string `db:"unread_key"` + Enabled bool `db:"enabled"` + Permission string `db:"permission"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} diff --git a/server/user_verification_test.go b/server/user_verification_test.go index 05c885a..531ab50 100644 --- a/server/user_verification_test.go +++ b/server/user_verification_test.go @@ -214,6 +214,7 @@ type testSecurityStore struct { realName *types.RealNameVerification actionToken *types.ActionToken independentPassword *types.IndependentPassword + youthMode *types.YouthModeSettings attempts int lockedUntil *time.Time notifications []types.SystemNotification @@ -292,6 +293,46 @@ func (s *testSecurityStore) UpdateIndependentPasswordAttempts(_ types.TenantID, return s.err } +func (s *testSecurityStore) GetYouthMode(_ types.TenantID, uid types.Uid) (*types.YouthModeSettings, error) { + if s.err != nil { + return nil, s.err + } + if s.youthMode == nil { + now := time.Now().UTC().Round(time.Millisecond) + s.youthMode = &types.YouthModeSettings{ + UserID: uid, + Enabled: false, + PolicyVersion: 1, + Restrictions: []string{"night_quiet", "content_filter", "payment_guard"}, + PasswordSet: s.independentPassword != nil, + CanEnable: s.independentPassword != nil, + CreatedAt: now, + UpdatedAt: now, + } + } + return s.youthMode, nil +} + +func (s *testSecurityStore) SetYouthMode(_ types.TenantID, uid types.Uid, enabled bool, policyVersion int, now time.Time) (*types.YouthModeSettings, error) { + if s.err != nil { + return nil, s.err + } + if s.youthMode == nil { + s.youthMode = &types.YouthModeSettings{UserID: uid, Restrictions: []string{"night_quiet", "content_filter", "payment_guard"}} + } + s.youthMode.Enabled = enabled + s.youthMode.PolicyVersion = policyVersion + s.youthMode.PasswordSet = true + s.youthMode.CanEnable = !enabled + s.youthMode.UpdatedAt = now + if enabled { + s.youthMode.EnabledAt = &now + } else { + s.youthMode.EnabledAt = nil + } + return s.youthMode, nil +} + func (s *testSecurityStore) CreateSystemNotification(_ types.TenantID, notification *types.SystemNotification) error { if notification != nil { s.notifications = append(s.notifications, *notification)