From 563381c1ca78aa08e1627dede162a35d205a225b Mon Sep 17 00:00:00 2001 From: zhenyi <434836402@qq.com> Date: Sun, 7 Jun 2026 11:30:56 +0800 Subject: [PATCH] feat: init --- .gitignore | 6 + .idea/.gitignore | 10 + Cargo.lock | 5360 +++++++++++++++++ Cargo.toml | 52 + build.rs | 29 + cache/lru.rs | 304 + cache/mod.rs | 97 + cache/redis.rs | 117 + config/aiprovider.rs | 12 + config/app.rs | 8 + config/channelaiprovider.rs | 32 + config/database.rs | 87 + config/embedaiprovider.rs | 27 + config/etcd.rs | 59 + config/lru.rs | 16 + config/mod.rs | 90 + config/nats.rs | 54 + config/qdrant.rs | 63 + config/redis.rs | 87 + config/rpc.rs | 30 + config/s3.rs | 64 + error.rs | 105 + etcd/discovery.rs | 160 + etcd/mod.rs | 88 + etcd/register.rs | 113 + etcd/types.rs | 10 + immediate/bridge.rs | 200 + immediate/dedup.rs | 58 + immediate/envelope.rs | 39 + immediate/handler.rs | 447 ++ immediate/inbound.rs | 68 + immediate/limiter.rs | 46 + immediate/mod.rs | 36 + immediate/nats.rs | 81 + immediate/outbound.rs | 256 + immediate/rate_limit.rs | 102 + immediate/reconnect.rs | 101 + immediate/redis_keys.rs | 19 + immediate/runtime.rs | 52 + immediate/seq.rs | 117 + immediate/session.rs | 301 + immediate/session_redis.rs | 93 + immediate/sink.rs | 53 + immediate/typing.rs | 71 + lib.rs | 12 + main.rs | 3 + migrate/001_init.sql | 2107 +++++++ migrate/002_triggers.sql | 667 ++ migrate/003_user_2fa.sql | 18 + migrate/004_auth_constraints.sql | 8 + migrate/005_issue_workspace_upgrade.sql | 124 + migrate/006_pr_review_fork_protection.sql | 76 + migrate/007_issue_reaction.sql | 16 + migrate/008_wiki.sql | 34 + migrate/009_im_features.sql | 320 + migrate/010_channel_kinds.sql | 187 + migrate/011_announcement.sql | 145 + migrate/012_im_message_seq.sql | 13 + models/agents/agent.rs | 28 + models/agents/agent_event_subscriptions.rs | 19 + models/agents/agent_execution_steps.rs | 24 + models/agents/agent_executions.rs | 25 + models/agents/agent_feedback.rs | 18 + models/agents/agent_schedules.rs | 22 + models/agents/agent_versions.rs | 24 + models/agents/agent_workspace_bindings.rs | 18 + models/agents/mod.rs | 17 + models/ais/ai_model_capabilities.rs | 15 + models/ais/ai_model_health.rs | 16 + models/ais/ai_model_versions.rs | 19 + models/ais/ai_models.rs | 30 + models/ais/mod.rs | 9 + models/channels/article_comments.rs | 18 + models/channels/article_cross_posts.rs | 22 + models/channels/article_reactions.rs | 14 + models/channels/articles.rs | 35 + models/channels/channel.rs | 43 + models/channels/channel_categories.rs | 15 + models/channels/channel_events.rs | 18 + models/channels/channel_follows.rs | 23 + models/channels/channel_invitations.rs | 20 + models/channels/channel_member_roles.rs | 17 + models/channels/channel_members.rs | 21 + .../channels/channel_permission_overwrites.rs | 17 + models/channels/channel_repo_links.rs | 17 + models/channels/channel_slash_commands.rs | 20 + models/channels/channel_stats.rs | 16 + models/channels/channel_webhooks.rs | 20 + models/channels/custom_emojis.rs | 16 + models/channels/forum_tags.rs | 18 + models/channels/im_integrations.rs | 24 + models/channels/message.rs | 23 + models/channels/message_attachments.rs | 21 + models/channels/message_bookmarks.rs | 14 + models/channels/message_drafts.rs | 17 + models/channels/message_edit_history.rs | 13 + models/channels/message_embeds.rs | 34 + models/channels/message_mentions.rs | 14 + models/channels/message_pins.rs | 12 + models/channels/message_poll_options.rs | 16 + models/channels/message_poll_votes.rs | 13 + models/channels/message_polls.rs | 22 + models/channels/message_reactions.rs | 13 + models/channels/message_threads.rs | 27 + models/channels/mod.rs | 73 + models/channels/saved_messages.rs | 13 + models/channels/stages.rs | 20 + models/channels/thread_read_states.rs | 14 + models/channels/voice_participants.rs | 21 + models/common.rs | 794 +++ models/conversations/conversation.rs | 29 + .../conversations/conversation_attachments.rs | 21 + .../conversations/conversation_bookmarks.rs | 15 + models/conversations/conversation_messages.rs | 26 + .../conversation_participants.rs | 22 + .../conversations/conversation_summaries.rs | 20 + .../conversations/conversation_tool_calls.rs | 22 + models/conversations/mod.rs | 15 + models/db.rs | 130 + models/issues/issue.rs | 25 + models/issues/issue_assignees.rs | 12 + models/issues/issue_comments.rs | 16 + models/issues/issue_commit_relations.rs | 16 + models/issues/issue_events.rs | 16 + models/issues/issue_label_relations.rs | 12 + models/issues/issue_labels.rs | 15 + models/issues/issue_milestones.rs | 18 + models/issues/issue_pr_relations.rs | 14 + models/issues/issue_queries.rs | 43 + models/issues/issue_reaction.rs | 15 + models/issues/issue_reminder.rs | 16 + models/issues/issue_repo_relations.rs | 14 + models/issues/issue_stats.rs | 15 + models/issues/issue_subscribers.rs | 14 + models/issues/issue_templates.rs | 18 + models/issues/mod.rs | 32 + models/json_types.rs | 94 + models/mod.rs | 14 + models/notifications/mod.rs | 11 + models/notifications/notification.rs | 31 + models/notifications/notification_blocks.rs | 20 + .../notifications/notification_deliveries.rs | 24 + .../notification_subscriptions.rs | 21 + .../notifications/notification_templates.rs | 21 + models/prs/mod.rs | 30 + models/prs/pr_assignees.rs | 12 + models/prs/pr_check_runs.rs | 20 + models/prs/pr_commits.rs | 15 + models/prs/pr_events.rs | 16 + models/prs/pr_files.rs | 19 + models/prs/pr_label_relations.rs | 12 + models/prs/pr_labels.rs | 15 + models/prs/pr_merge_strategy.rs | 18 + models/prs/pr_reactions.rs | 15 + models/prs/pr_review.rs | 19 + models/prs/pr_review_comment.rs | 22 + models/prs/pr_status.rs | 19 + models/prs/pr_subscriptions.rs | 14 + models/prs/pull_request.rs | 31 + models/prs/pull_request_queries.rs | 49 + models/repos/branch_protection_rule.rs | 27 + models/repos/mod.rs | 36 + models/repos/repo.rs | 26 + models/repos/repo_branches.rs | 18 + models/repos/repo_commit_comments.rs | 21 + models/repos/repo_commit_statuses.rs | 20 + models/repos/repo_deploy_keys.rs | 21 + models/repos/repo_fork.rs | 12 + models/repos/repo_invitations.rs | 19 + models/repos/repo_members.rs | 18 + models/repos/repo_push_commit.rs | 18 + models/repos/repo_push_lock.rs | 22 + models/repos/repo_queries.rs | 105 + models/repos/repo_releases.rs | 20 + models/repos/repo_stars.rs | 11 + models/repos/repo_stats.rs | 20 + models/repos/repo_tags.rs | 15 + models/repos/repo_watches.rs | 14 + models/repos/repo_webhooks.rs | 19 + models/users/mod.rs | 40 + models/users/user.rs | 22 + models/users/user_2fa.rs | 13 + models/users/user_activity.rs | 25 + models/users/user_appearance.rs | 18 + models/users/user_block.rs | 11 + models/users/user_device.rs | 19 + models/users/user_follow.rs | 10 + models/users/user_gpg_key.rs | 18 + models/users/user_mail.rs | 16 + models/users/user_notify_setting.rs | 18 + models/users/user_oauth.rs | 19 + models/users/user_password.rs | 16 + models/users/user_password_reset.rs | 15 + models/users/user_personal_access_token.rs | 18 + models/users/user_presence.rs | 19 + models/users/user_profile.rs | 34 + models/users/user_queries.rs | 99 + models/users/user_security_log.rs | 16 + models/users/user_session.rs | 17 + models/users/user_ssh_key.rs | 19 + models/wiki/mod.rs | 5 + models/wiki/wiki_page.rs | 18 + models/wiki/wiki_page_revision.rs | 15 + models/workspaces/mod.rs | 26 + models/workspaces/workspace.rs | 22 + models/workspaces/workspace_audit_logs.rs | 18 + models/workspaces/workspace_billing.rs | 21 + .../workspaces/workspace_custom_branding.rs | 17 + models/workspaces/workspace_domains.rs | 16 + models/workspaces/workspace_integrations.rs | 20 + models/workspaces/workspace_invitations.rs | 19 + models/workspaces/workspace_members.rs | 18 + .../workspaces/workspace_pending_approvals.rs | 19 + models/workspaces/workspace_queries.rs | 102 + models/workspaces/workspace_settings.rs | 18 + models/workspaces/workspace_stats.rs | 17 + models/workspaces/workspace_webhooks.rs | 19 + pb/email.rs | 4 + pb/mod.rs | 84 + pb/repo.rs | 4 + proto/email/email.proto | 87 + proto/git/archive.proto | 58 + proto/git/blame.proto | 56 + proto/git/branch.proto | 114 + proto/git/commit.proto | 165 + proto/git/diff.proto | 140 + proto/git/merge.proto | 139 + proto/git/oid.proto | 64 + proto/git/pack.proto | 134 + proto/git/repository.proto | 157 + proto/git/tag.proto | 67 + proto/git/tagger.proto | 51 + proto/git/tree.proto | 130 + queue/mod.rs | 183 + queue/publisher.rs | 65 + queue/subscriber.rs | 202 + service/auth/captcha.rs | 85 + service/auth/email.rs | 202 + service/auth/login.rs | 196 + service/auth/logout.rs | 14 + service/auth/me.rs | 47 + service/auth/mod.rs | 25 + service/auth/register.rs | 239 + service/auth/reset_pass.rs | 187 + service/auth/rsa.rs | 145 + service/auth/totp.rs | 413 ++ service/context.rs | 65 + service/im/articles.rs | 715 +++ service/im/categories.rs | 176 + service/im/channels.rs | 554 ++ service/im/delivery_trace.rs | 45 + service/im/drafts.rs | 162 + service/im/events.rs | 100 + service/im/follows.rs | 232 + service/im/members.rs | 410 ++ service/im/messages.rs | 889 +++ service/im/mod.rs | 58 + service/im/polls.rs | 372 ++ service/im/presence.rs | 244 + service/im/reactions.rs | 261 + service/im/session.rs | 12 + service/im/threads.rs | 321 + service/im/util.rs | 64 + service/issues/assignees.rs | 123 + service/issues/comments.rs | 230 + service/issues/core.rs | 867 +++ service/issues/events.rs | 63 + service/issues/labels.rs | 268 + service/issues/milestones.rs | 137 + service/issues/mod.rs | 12 + service/issues/pr_relations.rs | 122 + service/issues/reactions.rs | 108 + service/issues/repo_relations.rs | 118 + service/issues/subscribers.rs | 126 + service/issues/templates.rs | 158 + service/issues/util.rs | 3 + service/mod.rs | 121 + service/notify/blocks.rs | 127 + service/notify/core.rs | 141 + service/notify/deliveries.rs | 85 + service/notify/mod.rs | 6 + service/notify/subscriptions.rs | 183 + service/notify/templates.rs | 210 + service/notify/util.rs | 69 + service/pr/assignees.rs | 119 + service/pr/check_runs.rs | 189 + service/pr/commits.rs | 29 + service/pr/core.rs | 1084 ++++ service/pr/events.rs | 73 + service/pr/files.rs | 34 + service/pr/labels.rs | 225 + service/pr/merge_strategy.rs | 132 + service/pr/mod.rs | 13 + service/pr/reactions.rs | 96 + service/pr/reviews.rs | 431 ++ service/pr/status.rs | 29 + service/pr/subscriptions.rs | 123 + service/pr/util.rs | 3 + service/repo/branches.rs | 278 + service/repo/commit_status.rs | 241 + service/repo/core.rs | 789 +++ service/repo/deploy_keys.rs | 174 + service/repo/fork.rs | 258 + service/repo/git/blame.rs | 43 + service/repo/git/branch.rs | 142 + service/repo/git/commit.rs | 79 + service/repo/git/diff.rs | 72 + service/repo/git/merge.rs | 372 ++ service/repo/git/mod.rs | 32 + service/repo/git/repository.rs | 123 + service/repo/git/tag.rs | 96 + service/repo/git/tree.rs | 77 + service/repo/invitations.rs | 343 ++ service/repo/members.rs | 317 + service/repo/mod.rs | 16 + service/repo/protection.rs | 389 ++ service/repo/releases.rs | 244 + service/repo/stars.rs | 142 + service/repo/stats.rs | 152 + service/repo/tags.rs | 159 + service/repo/util.rs | 3 + service/repo/watches.rs | 166 + service/repo/webhooks.rs | 269 + service/user/account.rs | 235 + service/user/appearance.rs | 107 + service/user/keys.rs | 232 + service/user/mod.rs | 7 + service/user/notify.rs | 122 + service/user/profile.rs | 90 + service/user/security.rs | 331 + service/user/util.rs | 3 + service/util.rs | 154 + service/wiki/core.rs | 348 ++ service/wiki/mod.rs | 3 + service/wiki/revisions.rs | 81 + service/wiki/util.rs | 1 + service/workspace/approvals.rs | 148 + service/workspace/audit.rs | 69 + service/workspace/billing.rs | 118 + service/workspace/branding.rs | 123 + service/workspace/core.rs | 625 ++ service/workspace/domains.rs | 263 + service/workspace/integrations.rs | 220 + service/workspace/invitations.rs | 323 + service/workspace/members.rs | 350 ++ service/workspace/mod.rs | 13 + service/workspace/settings.rs | 128 + service/workspace/stats.rs | 117 + service/workspace/util.rs | 3 + service/workspace/webhooks.rs | 267 + session/config.rs | 36 + session/mod.rs | 9 + session/session.rs | 195 + session/storage/format.rs | 60 + session/storage/interface.rs | 36 + session/storage/mod.rs | 12 + session/storage/redis.rs | 82 + session/storage/session_key.rs | 28 + session/storage/utils.rs | 7 + storage/mod.rs | 1 + storage/s3.rs | 101 + 361 files changed, 41327 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 build.rs create mode 100644 cache/lru.rs create mode 100644 cache/mod.rs create mode 100644 cache/redis.rs create mode 100644 config/aiprovider.rs create mode 100644 config/app.rs create mode 100644 config/channelaiprovider.rs create mode 100644 config/database.rs create mode 100644 config/embedaiprovider.rs create mode 100644 config/etcd.rs create mode 100644 config/lru.rs create mode 100644 config/mod.rs create mode 100644 config/nats.rs create mode 100644 config/qdrant.rs create mode 100644 config/redis.rs create mode 100644 config/rpc.rs create mode 100644 config/s3.rs create mode 100644 error.rs create mode 100644 etcd/discovery.rs create mode 100644 etcd/mod.rs create mode 100644 etcd/register.rs create mode 100644 etcd/types.rs create mode 100644 immediate/bridge.rs create mode 100644 immediate/dedup.rs create mode 100644 immediate/envelope.rs create mode 100644 immediate/handler.rs create mode 100644 immediate/inbound.rs create mode 100644 immediate/limiter.rs create mode 100644 immediate/mod.rs create mode 100644 immediate/nats.rs create mode 100644 immediate/outbound.rs create mode 100644 immediate/rate_limit.rs create mode 100644 immediate/reconnect.rs create mode 100644 immediate/redis_keys.rs create mode 100644 immediate/runtime.rs create mode 100644 immediate/seq.rs create mode 100644 immediate/session.rs create mode 100644 immediate/session_redis.rs create mode 100644 immediate/sink.rs create mode 100644 immediate/typing.rs create mode 100644 lib.rs create mode 100644 main.rs create mode 100644 migrate/001_init.sql create mode 100644 migrate/002_triggers.sql create mode 100644 migrate/003_user_2fa.sql create mode 100644 migrate/004_auth_constraints.sql create mode 100644 migrate/005_issue_workspace_upgrade.sql create mode 100644 migrate/006_pr_review_fork_protection.sql create mode 100644 migrate/007_issue_reaction.sql create mode 100644 migrate/008_wiki.sql create mode 100644 migrate/009_im_features.sql create mode 100644 migrate/010_channel_kinds.sql create mode 100644 migrate/011_announcement.sql create mode 100644 migrate/012_im_message_seq.sql create mode 100644 models/agents/agent.rs create mode 100644 models/agents/agent_event_subscriptions.rs create mode 100644 models/agents/agent_execution_steps.rs create mode 100644 models/agents/agent_executions.rs create mode 100644 models/agents/agent_feedback.rs create mode 100644 models/agents/agent_schedules.rs create mode 100644 models/agents/agent_versions.rs create mode 100644 models/agents/agent_workspace_bindings.rs create mode 100644 models/agents/mod.rs create mode 100644 models/ais/ai_model_capabilities.rs create mode 100644 models/ais/ai_model_health.rs create mode 100644 models/ais/ai_model_versions.rs create mode 100644 models/ais/ai_models.rs create mode 100644 models/ais/mod.rs create mode 100644 models/channels/article_comments.rs create mode 100644 models/channels/article_cross_posts.rs create mode 100644 models/channels/article_reactions.rs create mode 100644 models/channels/articles.rs create mode 100644 models/channels/channel.rs create mode 100644 models/channels/channel_categories.rs create mode 100644 models/channels/channel_events.rs create mode 100644 models/channels/channel_follows.rs create mode 100644 models/channels/channel_invitations.rs create mode 100644 models/channels/channel_member_roles.rs create mode 100644 models/channels/channel_members.rs create mode 100644 models/channels/channel_permission_overwrites.rs create mode 100644 models/channels/channel_repo_links.rs create mode 100644 models/channels/channel_slash_commands.rs create mode 100644 models/channels/channel_stats.rs create mode 100644 models/channels/channel_webhooks.rs create mode 100644 models/channels/custom_emojis.rs create mode 100644 models/channels/forum_tags.rs create mode 100644 models/channels/im_integrations.rs create mode 100644 models/channels/message.rs create mode 100644 models/channels/message_attachments.rs create mode 100644 models/channels/message_bookmarks.rs create mode 100644 models/channels/message_drafts.rs create mode 100644 models/channels/message_edit_history.rs create mode 100644 models/channels/message_embeds.rs create mode 100644 models/channels/message_mentions.rs create mode 100644 models/channels/message_pins.rs create mode 100644 models/channels/message_poll_options.rs create mode 100644 models/channels/message_poll_votes.rs create mode 100644 models/channels/message_polls.rs create mode 100644 models/channels/message_reactions.rs create mode 100644 models/channels/message_threads.rs create mode 100644 models/channels/mod.rs create mode 100644 models/channels/saved_messages.rs create mode 100644 models/channels/stages.rs create mode 100644 models/channels/thread_read_states.rs create mode 100644 models/channels/voice_participants.rs create mode 100644 models/common.rs create mode 100644 models/conversations/conversation.rs create mode 100644 models/conversations/conversation_attachments.rs create mode 100644 models/conversations/conversation_bookmarks.rs create mode 100644 models/conversations/conversation_messages.rs create mode 100644 models/conversations/conversation_participants.rs create mode 100644 models/conversations/conversation_summaries.rs create mode 100644 models/conversations/conversation_tool_calls.rs create mode 100644 models/conversations/mod.rs create mode 100644 models/db.rs create mode 100644 models/issues/issue.rs create mode 100644 models/issues/issue_assignees.rs create mode 100644 models/issues/issue_comments.rs create mode 100644 models/issues/issue_commit_relations.rs create mode 100644 models/issues/issue_events.rs create mode 100644 models/issues/issue_label_relations.rs create mode 100644 models/issues/issue_labels.rs create mode 100644 models/issues/issue_milestones.rs create mode 100644 models/issues/issue_pr_relations.rs create mode 100644 models/issues/issue_queries.rs create mode 100644 models/issues/issue_reaction.rs create mode 100644 models/issues/issue_reminder.rs create mode 100644 models/issues/issue_repo_relations.rs create mode 100644 models/issues/issue_stats.rs create mode 100644 models/issues/issue_subscribers.rs create mode 100644 models/issues/issue_templates.rs create mode 100644 models/issues/mod.rs create mode 100644 models/json_types.rs create mode 100644 models/mod.rs create mode 100644 models/notifications/mod.rs create mode 100644 models/notifications/notification.rs create mode 100644 models/notifications/notification_blocks.rs create mode 100644 models/notifications/notification_deliveries.rs create mode 100644 models/notifications/notification_subscriptions.rs create mode 100644 models/notifications/notification_templates.rs create mode 100644 models/prs/mod.rs create mode 100644 models/prs/pr_assignees.rs create mode 100644 models/prs/pr_check_runs.rs create mode 100644 models/prs/pr_commits.rs create mode 100644 models/prs/pr_events.rs create mode 100644 models/prs/pr_files.rs create mode 100644 models/prs/pr_label_relations.rs create mode 100644 models/prs/pr_labels.rs create mode 100644 models/prs/pr_merge_strategy.rs create mode 100644 models/prs/pr_reactions.rs create mode 100644 models/prs/pr_review.rs create mode 100644 models/prs/pr_review_comment.rs create mode 100644 models/prs/pr_status.rs create mode 100644 models/prs/pr_subscriptions.rs create mode 100644 models/prs/pull_request.rs create mode 100644 models/prs/pull_request_queries.rs create mode 100644 models/repos/branch_protection_rule.rs create mode 100644 models/repos/mod.rs create mode 100644 models/repos/repo.rs create mode 100644 models/repos/repo_branches.rs create mode 100644 models/repos/repo_commit_comments.rs create mode 100644 models/repos/repo_commit_statuses.rs create mode 100644 models/repos/repo_deploy_keys.rs create mode 100644 models/repos/repo_fork.rs create mode 100644 models/repos/repo_invitations.rs create mode 100644 models/repos/repo_members.rs create mode 100644 models/repos/repo_push_commit.rs create mode 100644 models/repos/repo_push_lock.rs create mode 100644 models/repos/repo_queries.rs create mode 100644 models/repos/repo_releases.rs create mode 100644 models/repos/repo_stars.rs create mode 100644 models/repos/repo_stats.rs create mode 100644 models/repos/repo_tags.rs create mode 100644 models/repos/repo_watches.rs create mode 100644 models/repos/repo_webhooks.rs create mode 100644 models/users/mod.rs create mode 100644 models/users/user.rs create mode 100644 models/users/user_2fa.rs create mode 100644 models/users/user_activity.rs create mode 100644 models/users/user_appearance.rs create mode 100644 models/users/user_block.rs create mode 100644 models/users/user_device.rs create mode 100644 models/users/user_follow.rs create mode 100644 models/users/user_gpg_key.rs create mode 100644 models/users/user_mail.rs create mode 100644 models/users/user_notify_setting.rs create mode 100644 models/users/user_oauth.rs create mode 100644 models/users/user_password.rs create mode 100644 models/users/user_password_reset.rs create mode 100644 models/users/user_personal_access_token.rs create mode 100644 models/users/user_presence.rs create mode 100644 models/users/user_profile.rs create mode 100644 models/users/user_queries.rs create mode 100644 models/users/user_security_log.rs create mode 100644 models/users/user_session.rs create mode 100644 models/users/user_ssh_key.rs create mode 100644 models/wiki/mod.rs create mode 100644 models/wiki/wiki_page.rs create mode 100644 models/wiki/wiki_page_revision.rs create mode 100644 models/workspaces/mod.rs create mode 100644 models/workspaces/workspace.rs create mode 100644 models/workspaces/workspace_audit_logs.rs create mode 100644 models/workspaces/workspace_billing.rs create mode 100644 models/workspaces/workspace_custom_branding.rs create mode 100644 models/workspaces/workspace_domains.rs create mode 100644 models/workspaces/workspace_integrations.rs create mode 100644 models/workspaces/workspace_invitations.rs create mode 100644 models/workspaces/workspace_members.rs create mode 100644 models/workspaces/workspace_pending_approvals.rs create mode 100644 models/workspaces/workspace_queries.rs create mode 100644 models/workspaces/workspace_settings.rs create mode 100644 models/workspaces/workspace_stats.rs create mode 100644 models/workspaces/workspace_webhooks.rs create mode 100644 pb/email.rs create mode 100644 pb/mod.rs create mode 100644 pb/repo.rs create mode 100644 proto/email/email.proto create mode 100644 proto/git/archive.proto create mode 100644 proto/git/blame.proto create mode 100644 proto/git/branch.proto create mode 100644 proto/git/commit.proto create mode 100644 proto/git/diff.proto create mode 100644 proto/git/merge.proto create mode 100644 proto/git/oid.proto create mode 100644 proto/git/pack.proto create mode 100644 proto/git/repository.proto create mode 100644 proto/git/tag.proto create mode 100644 proto/git/tagger.proto create mode 100644 proto/git/tree.proto create mode 100644 queue/mod.rs create mode 100644 queue/publisher.rs create mode 100644 queue/subscriber.rs create mode 100644 service/auth/captcha.rs create mode 100644 service/auth/email.rs create mode 100644 service/auth/login.rs create mode 100644 service/auth/logout.rs create mode 100644 service/auth/me.rs create mode 100644 service/auth/mod.rs create mode 100644 service/auth/register.rs create mode 100644 service/auth/reset_pass.rs create mode 100644 service/auth/rsa.rs create mode 100644 service/auth/totp.rs create mode 100644 service/context.rs create mode 100644 service/im/articles.rs create mode 100644 service/im/categories.rs create mode 100644 service/im/channels.rs create mode 100644 service/im/delivery_trace.rs create mode 100644 service/im/drafts.rs create mode 100644 service/im/events.rs create mode 100644 service/im/follows.rs create mode 100644 service/im/members.rs create mode 100644 service/im/messages.rs create mode 100644 service/im/mod.rs create mode 100644 service/im/polls.rs create mode 100644 service/im/presence.rs create mode 100644 service/im/reactions.rs create mode 100644 service/im/session.rs create mode 100644 service/im/threads.rs create mode 100644 service/im/util.rs create mode 100644 service/issues/assignees.rs create mode 100644 service/issues/comments.rs create mode 100644 service/issues/core.rs create mode 100644 service/issues/events.rs create mode 100644 service/issues/labels.rs create mode 100644 service/issues/milestones.rs create mode 100644 service/issues/mod.rs create mode 100644 service/issues/pr_relations.rs create mode 100644 service/issues/reactions.rs create mode 100644 service/issues/repo_relations.rs create mode 100644 service/issues/subscribers.rs create mode 100644 service/issues/templates.rs create mode 100644 service/issues/util.rs create mode 100644 service/mod.rs create mode 100644 service/notify/blocks.rs create mode 100644 service/notify/core.rs create mode 100644 service/notify/deliveries.rs create mode 100644 service/notify/mod.rs create mode 100644 service/notify/subscriptions.rs create mode 100644 service/notify/templates.rs create mode 100644 service/notify/util.rs create mode 100644 service/pr/assignees.rs create mode 100644 service/pr/check_runs.rs create mode 100644 service/pr/commits.rs create mode 100644 service/pr/core.rs create mode 100644 service/pr/events.rs create mode 100644 service/pr/files.rs create mode 100644 service/pr/labels.rs create mode 100644 service/pr/merge_strategy.rs create mode 100644 service/pr/mod.rs create mode 100644 service/pr/reactions.rs create mode 100644 service/pr/reviews.rs create mode 100644 service/pr/status.rs create mode 100644 service/pr/subscriptions.rs create mode 100644 service/pr/util.rs create mode 100644 service/repo/branches.rs create mode 100644 service/repo/commit_status.rs create mode 100644 service/repo/core.rs create mode 100644 service/repo/deploy_keys.rs create mode 100644 service/repo/fork.rs create mode 100644 service/repo/git/blame.rs create mode 100644 service/repo/git/branch.rs create mode 100644 service/repo/git/commit.rs create mode 100644 service/repo/git/diff.rs create mode 100644 service/repo/git/merge.rs create mode 100644 service/repo/git/mod.rs create mode 100644 service/repo/git/repository.rs create mode 100644 service/repo/git/tag.rs create mode 100644 service/repo/git/tree.rs create mode 100644 service/repo/invitations.rs create mode 100644 service/repo/members.rs create mode 100644 service/repo/mod.rs create mode 100644 service/repo/protection.rs create mode 100644 service/repo/releases.rs create mode 100644 service/repo/stars.rs create mode 100644 service/repo/stats.rs create mode 100644 service/repo/tags.rs create mode 100644 service/repo/util.rs create mode 100644 service/repo/watches.rs create mode 100644 service/repo/webhooks.rs create mode 100644 service/user/account.rs create mode 100644 service/user/appearance.rs create mode 100644 service/user/keys.rs create mode 100644 service/user/mod.rs create mode 100644 service/user/notify.rs create mode 100644 service/user/profile.rs create mode 100644 service/user/security.rs create mode 100644 service/user/util.rs create mode 100644 service/util.rs create mode 100644 service/wiki/core.rs create mode 100644 service/wiki/mod.rs create mode 100644 service/wiki/revisions.rs create mode 100644 service/wiki/util.rs create mode 100644 service/workspace/approvals.rs create mode 100644 service/workspace/audit.rs create mode 100644 service/workspace/billing.rs create mode 100644 service/workspace/branding.rs create mode 100644 service/workspace/core.rs create mode 100644 service/workspace/domains.rs create mode 100644 service/workspace/integrations.rs create mode 100644 service/workspace/invitations.rs create mode 100644 service/workspace/members.rs create mode 100644 service/workspace/mod.rs create mode 100644 service/workspace/settings.rs create mode 100644 service/workspace/stats.rs create mode 100644 service/workspace/util.rs create mode 100644 service/workspace/webhooks.rs create mode 100644 session/config.rs create mode 100644 session/mod.rs create mode 100644 session/session.rs create mode 100644 session/storage/format.rs create mode 100644 session/storage/interface.rs create mode 100644 session/storage/mod.rs create mode 100644 session/storage/redis.rs create mode 100644 session/storage/session_key.rs create mode 100644 session/storage/utils.rs create mode 100644 storage/mod.rs create mode 100644 storage/s3.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44ea996 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target +.idea +.codegraph +.claude +AGENT.md +CLAUDE.md \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..f6906f2 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6c4d61f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5360 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.6", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "appks" +version = "0.1.0" +dependencies = [ + "argon2", + "async-nats", + "base64", + "captcha-rs", + "chacha20poly1305", + "chrono", + "dashmap", + "dotenvy", + "etcd-client", + "futures-util", + "hkdf 0.12.4", + "hmac 0.12.1", + "object_store", + "prost 0.14.3", + "prost-types 0.14.3", + "r2d2", + "rand 0.8.6", + "redis", + "reqwest 0.13.4", + "rsa", + "serde", + "serde_json", + "sha1 0.10.6", + "sha2 0.10.9", + "sqlx", + "thiserror", + "tokio", + "tokio-stream", + "tonic 0.14.6", + "tonic-prost", + "tonic-prost-build", + "tracing", + "tracing-subscriber", + "url", + "utoipa", + "uuid", +] + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arcstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-nats" +version = "0.49.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad3cd6df81292728e2a8cb1f1dcb4d7e7a1ab59b80c14fbbcba2baf9d5cf86a" +dependencies = [ + "base64", + "bytes", + "futures-util", + "memchr", + "nkeys", + "nuid", + "pin-project", + "portable-atomic", + "rand 0.10.1", + "regex", + "ring", + "rustls-native-certs", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_nanos", + "serde_repr", + "thiserror", + "time", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", + "tokio-websockets", + "tracing", + "tryhard", + "url", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core 0.4.5", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core 0.5.6", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "captcha-rs" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea23e9ba29e482e553d48391849195b95f055ffb059f785a28c9c5a046844223" +dependencies = [ + "ab_glyph", + "base64", + "image", + "imageproc", + "rand 0.9.4", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.6", + "inout", + "zeroize", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc16" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2 0.10.9", + "signature", + "subtle", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcd-client" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0452bcc559431b16f472b7ab86e2f9ccd5f3c2da3795afbd6b773665e047fe" +dependencies = [ + "http", + "prost 0.13.5", + "tokio", + "tokio-stream", + "tonic 0.12.3", + "tonic-build 0.12.3", + "tower 0.4.13", + "tower-service", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "glam" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333928d5eb103c5d4050533cec0384302db6be8ef7d3cebd30ec6a35350353da" + +[[package]] +name = "glam" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3abb554f8ee44336b72d522e0a7fe86a29e09f839a36022fa869a7dfe941a54b" + +[[package]] +name = "glam" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4126c0479ccf7e8664c36a2d719f5f2c140fbb4f9090008098d2c291fa5b3f16" + +[[package]] +name = "glam" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01732b97afd8508eee3333a541b9f7610f454bb818669e66e90f5f57c93a776" + +[[package]] +name = "glam" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525a3e490ba77b8e326fb67d4b44b4bd2f920f44d4cc73ccec50adc68e3bee34" + +[[package]] +name = "glam" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8509e6791516e81c1a630d0bd7fbac36d2fa8712a9da8662e716b52d5051ca" + +[[package]] +name = "glam" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e957e744be03f5801a55472f593d43fabdebf25a4585db250f04d86b1675f" + +[[package]] +name = "glam" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518faa5064866338b013ff9b2350dc318e14cc4fcd6cb8206d7e7c9886c98815" + +[[package]] +name = "glam" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f597d56c1bd55a811a1be189459e8fad2bbc272616375602443bdfb37fa774" + +[[package]] +name = "glam" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e4afd9ad95555081e109fe1d21f2a30c691b5f0919c67dfa690a2e1eb6bd51c" + +[[package]] +name = "glam" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5418c17512bdf42730f9032c74e1ae39afc408745ebb2acf72fbc4691c17945" + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "glam" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" + +[[package]] +name = "glam" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779ae4bf7e8421cf91c0b3b64e7e8b40b862fba4d393f59150042de7c4965a94" + +[[package]] +name = "glam" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" + +[[package]] +name = "glam" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" + +[[package]] +name = "glam" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7" + +[[package]] +name = "glam" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.4", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imageproc" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645329c490783f3ea465d2b6c7c08286fece97f15e714fd533b6c70a3ead2252" +dependencies = [ + "ab_glyph", + "approx", + "getrandom 0.3.4", + "image", + "itertools", + "nalgebra", + "num", + "rand 0.9.4", + "rand_distr", + "rayon", + "rustdct", +] + +[[package]] +name = "imgref" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nalgebra" +version = "0.34.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df76ea0ff5c7e6b88689085804d6132ded0ddb9de5ca5b8aeb9eeadc0508a70a" +dependencies = [ + "approx", + "glam 0.14.0", + "glam 0.15.2", + "glam 0.16.0", + "glam 0.17.3", + "glam 0.18.0", + "glam 0.19.0", + "glam 0.20.5", + "glam 0.21.3", + "glam 0.22.0", + "glam 0.23.0", + "glam 0.24.2", + "glam 0.25.0", + "glam 0.27.0", + "glam 0.28.0", + "glam 0.29.3", + "glam 0.30.10", + "glam 0.31.1", + "glam 0.32.1", + "matrixmultiply", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nkeys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf" +dependencies = [ + "data-encoding", + "ed25519", + "ed25519-dalek", + "getrandom 0.2.17", + "log", + "rand 0.8.6", + "signatory", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "nuid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" +dependencies = [ + "rand 0.8.6", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object_store" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622acbc9100d3c10e2ee15804b0caa40e55c933d5aa53814cd520805b7958a49" +dependencies = [ + "async-trait", + "base64", + "bytes", + "chrono", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body-util", + "humantime", + "hyper", + "itertools", + "md-5 0.10.6", + "parking_lot", + "percent-encoding", + "quick-xml", + "rand 0.10.1", + "reqwest 0.12.28", + "ring", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.0", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive 0.14.3", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph 0.7.1", + "prettyplease", + "prost 0.13.5", + "prost-types 0.13.5", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph 0.8.3", + "prettyplease", + "prost 0.14.3", + "prost-types 0.14.3", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost 0.14.3", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.4", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.4", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20 0.10.0", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand 0.9.4", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redis" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a12e6b5f4d8ef33944e833e2b1859ad478deab6e431d7337b30ee2efe21f7543" +dependencies = [ + "arcstr", + "async-lock", + "bytes", + "cfg-if", + "combine", + "crc16", + "futures-util", + "itoa", + "log", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "r2d2", + "rand 0.9.4", + "ryu", + "sha1_smol", + "socket2 0.6.4", + "tokio", + "tokio-util", + "url", + "xxhash-rust", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower 0.5.3", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower 0.5.3", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustdct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b61555105d6a9bf98797c063c362a1d24ed8ab0431655e38f1cf51e52089551" +dependencies = [ + "rustfft", +] + +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_nanos" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93142f0367a4cc53ae0fead1bcda39e85beccfad3dcd717656cacab94b12985" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signatory" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" +dependencies = [ + "pkcs8", + "rand_core 0.6.4", + "signature", + "zeroize", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simba" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap 2.14.0", + "log", + "memchr", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "thiserror", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1 0.11.0", + "sha2 0.11.0", + "sqlx-core", + "thiserror", + "tracing", + "uuid", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf 0.13.0", + "hmac 0.13.0", + "itoa", + "log", + "md-5 0.11.0", + "memchr", + "rand 0.10.1", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "http", + "httparse", + "rand 0.8.6", + "ring", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tokio-util", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum 0.7.9", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost 0.13.5", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum 0.8.9", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2 0.6.4", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build 0.13.5", + "prost-types 0.13.5", + "quote", + "syn", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost 0.14.3", + "tonic 0.14.6", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build 0.14.3", + "prost-types 0.14.3", + "quote", + "syn", + "tempfile", + "tonic-build 0.14.6", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.6", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 2.14.0", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower 0.5.3", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tryhard" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..23bc8d8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "appks" +version = "0.1.0" +edition = "2024" + + +[lib] +name = "appks" +path = "lib.rs" + +[[bin]] +name = "appks" +path = "main.rs" +[dependencies] +sqlx = { version = "0.9.0", features = ["postgres","runtime-tokio","chrono","uuid","json"] } +tokio = { version = "1.52.3", features = ["full"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = { version = "1.0.150", features = [] } +chrono = { version = "0.4.19", features = ["serde"] } +uuid = { version = "1.23.1", features = ["serde","v4","v7"] } +reqwest = { version = "0.13.4", features = ["json"] } +tracing = { version = "0.1.44", features = [] } +tracing-subscriber = { version = "0.3.23", features = ["fmt"] } +dotenvy = "0.15.7" +thiserror = "2" +redis = { version = "1.2.1", features = ["cluster","cluster-async","aio","tokio-comp","r2d2"] } +r2d2 = { version = "0.8.10", features = [] } +dashmap = "6.1" +object_store = { version = "0.13.2", features = ["tokio","aws","cloud"] } +argon2 = "0.5" +rsa = "0.9" +chacha20poly1305 = "0.10" +hkdf = "0.12" +sha2 = "0.10" +sha1 = "0.10" +hmac = "0.12" +base64 = "0.22" +rand = "0.8" +captcha-rs = "0.5" +tonic = { version = "0.14.6", features = ["transport", "channel"] } +prost = "0.14.3" +prost-types = "0.14.3" +tonic-prost = "0.14.6" +url = "2.5" +etcd-client = "0.14" +tokio-stream = "0.1" +async-nats = "0.49" +futures-util = "0.3" +utoipa = { version = "5.5.0", features = ["uuid","chrono","auto_into_responses","actix_extras","decimal","macros"]} + +[build-dependencies] +tonic-prost-build = "0.14.6" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..7821ca5 --- /dev/null +++ b/build.rs @@ -0,0 +1,29 @@ +fn main() -> Result<(), Box> { + tonic_prost_build::configure() + .build_client(true) + .build_server(false) + .compile_protos(&["proto/email/email.proto"], &["proto/email"])?; + + tonic_prost_build::configure() + .build_client(true) + .build_server(false) + .compile_protos( + &[ + "proto/git/oid.proto", + "proto/git/tagger.proto", + "proto/git/repository.proto", + "proto/git/commit.proto", + "proto/git/branch.proto", + "proto/git/tag.proto", + "proto/git/tree.proto", + "proto/git/diff.proto", + "proto/git/merge.proto", + "proto/git/blame.proto", + "proto/git/archive.proto", + "proto/git/pack.proto", + ], + &["proto/git"], + )?; + + Ok(()) +} diff --git a/cache/lru.rs b/cache/lru.rs new file mode 100644 index 0000000..b25a738 --- /dev/null +++ b/cache/lru.rs @@ -0,0 +1,304 @@ +use dashmap::DashMap; +use std::collections::HashMap; +use std::hash::Hash; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +struct CacheEntry { + value: V, + expires_at: Instant, +} + +struct LruNode { + key: Option, + prev: usize, + next: usize, +} + +struct LruTracker { + nodes: Vec>, + key_to_idx: HashMap, + head: usize, + tail: usize, +} + +impl LruTracker { + fn new() -> Self { + let sentinel = LruNode { + key: None, + prev: 0, + next: 0, + }; + Self { + nodes: vec![sentinel], + key_to_idx: HashMap::new(), + head: 0, + tail: 0, + } + } + + fn touch(&mut self, key: &K) { + if let Some(&idx) = self.key_to_idx.get(key) { + self.detach(idx); + self.attach_front(idx); + } + } + + fn push_front(&mut self, key: K) -> usize { + let idx = self.nodes.len(); + self.nodes.push(LruNode { + key: Some(key.clone()), + prev: 0, + next: 0, + }); + self.key_to_idx.insert(key, idx); + self.attach_front(idx); + idx + } + + fn pop_back(&mut self) -> Option { + if self.tail == 0 { + return None; + } + let lru = self.tail; + let key = self.nodes[lru].key.take(); + self.detach(lru); + if let Some(ref k) = key { + self.key_to_idx.remove(k); + } + key + } + + fn remove(&mut self, key: &K) { + if let Some(&idx) = self.key_to_idx.get(key) { + self.detach(idx); + self.key_to_idx.remove(key); + } + } + + fn clear(&mut self) { + self.key_to_idx.clear(); + self.nodes.truncate(1); + self.head = 0; + self.tail = 0; + } + + fn len(&self) -> usize { + self.key_to_idx.len() + } + + fn detach(&mut self, idx: usize) { + let prev = self.nodes[idx].prev; + let next = self.nodes[idx].next; + + if prev != 0 { + self.nodes[prev].next = next; + } else { + self.head = next; + } + + if next != 0 { + self.nodes[next].prev = prev; + } else { + self.tail = prev; + } + } + + fn attach_front(&mut self, idx: usize) { + self.nodes[idx].prev = 0; + self.nodes[idx].next = self.head; + + if self.head != 0 { + self.nodes[self.head].prev = idx; + } else { + self.tail = idx; + } + + self.head = idx; + } +} + +pub struct LruTtlCache { + map: DashMap>, + lru: Mutex>, + capacity: usize, + ttl: Duration, +} + +impl LruTtlCache { + pub fn new(capacity: usize, ttl: Duration) -> Self { + Self { + map: DashMap::with_capacity(capacity), + lru: Mutex::new(LruTracker::new()), + capacity, + ttl, + } + } + + pub fn get(&self, key: &K) -> Option { + let entry = self.map.get(key)?; + let expired = entry.expires_at <= Instant::now(); + let value = entry.value.clone(); + drop(entry); + + if expired { + self.remove(key); + return None; + } + + if let Ok(mut lru) = self.lru.lock() { + lru.touch(key); + } + + Some(value) + } + + pub fn insert(&self, key: K, value: V) { + self.insert_with_ttl(key, value, self.ttl); + } + + pub fn insert_with_ttl(&self, key: K, value: V, ttl: Duration) { + let now = Instant::now(); + + if self.map.contains_key(&key) { + self.map.insert( + key.clone(), + CacheEntry { + value, + expires_at: now + ttl, + }, + ); + if let Ok(mut lru) = self.lru.lock() { + lru.touch(&key); + } + return; + } + + let mut lru = self.lru.lock().unwrap(); + + if lru.len() >= self.capacity + && let Some(evicted_key) = lru.pop_back() + { + self.map.remove(&evicted_key); + } + + self.map.insert( + key.clone(), + CacheEntry { + value, + expires_at: now + ttl, + }, + ); + lru.push_front(key); + } + + pub fn remove(&self, key: &K) -> Option { + if let Ok(mut lru) = self.lru.lock() { + lru.remove(key); + } + self.map.remove(key).map(|(_, entry)| entry.value) + } + + pub fn contains(&self, key: &K) -> bool { + self.map.contains_key(key) + } + + pub fn len(&self) -> usize { + self.map.len() + } + + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + + pub fn clear(&self) { + self.map.clear(); + if let Ok(mut lru) = self.lru.lock() { + lru.clear(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_insert_and_get() { + let cache = LruTtlCache::new(3, Duration::from_secs(60)); + cache.insert("a", 1); + cache.insert("b", 2); + cache.insert("c", 3); + + assert_eq!(cache.get(&"a"), Some(1)); + assert_eq!(cache.get(&"b"), Some(2)); + assert_eq!(cache.get(&"c"), Some(3)); + } + + #[test] + fn test_lru_eviction() { + let cache = LruTtlCache::new(2, Duration::from_secs(60)); + cache.insert("a", 1); + cache.insert("b", 2); + cache.get(&"a"); + cache.insert("c", 3); + + assert_eq!(cache.get(&"a"), Some(1)); + assert_eq!(cache.get(&"b"), None); + assert_eq!(cache.get(&"c"), Some(3)); + } + + #[test] + fn test_ttl_expiry() { + let cache = LruTtlCache::new(3, Duration::from_millis(10)); + cache.insert("a", 1); + std::thread::sleep(Duration::from_millis(20)); + + assert_eq!(cache.get(&"a"), None); + } + + #[test] + fn test_update_existing() { + let cache = LruTtlCache::new(3, Duration::from_secs(60)); + cache.insert("a", 1); + cache.insert("a", 100); + + assert_eq!(cache.get(&"a"), Some(100)); + assert_eq!(cache.len(), 1); + } + + #[test] + fn test_remove() { + let cache = LruTtlCache::new(3, Duration::from_secs(60)); + cache.insert("a", 1); + cache.insert("b", 2); + + assert_eq!(cache.remove(&"a"), Some(1)); + assert_eq!(cache.get(&"a"), None); + assert_eq!(cache.len(), 1); + } + + #[test] + fn test_concurrent_access() { + let cache = std::sync::Arc::new(LruTtlCache::new(10, Duration::from_secs(60))); + let c1 = cache.clone(); + let c2 = cache.clone(); + + let t1 = std::thread::spawn(move || { + for i in 0..100 { + c1.insert(i, i * 2); + } + }); + + let t2 = std::thread::spawn(move || { + for i in 0..100 { + let _ = c2.get(&i); + } + }); + + t1.join().unwrap(); + t2.join().unwrap(); + + assert!(cache.len() <= 10); + } +} diff --git a/cache/mod.rs b/cache/mod.rs new file mode 100644 index 0000000..bc918ad --- /dev/null +++ b/cache/mod.rs @@ -0,0 +1,97 @@ +use crate::cache::lru::LruTtlCache; +use crate::cache::redis::AppRedis; +use crate::config::AppConfig; +use crate::error::AppResult; +use ::redis::Cmd; +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::time::Duration; + +pub mod lru; +pub mod redis; + +pub struct AppCache { + l1: LruTtlCache, + l2: AppRedis, + key_prefix: String, + default_ttl: Duration, +} + +impl AppCache { + pub fn from_config(config: &AppConfig) -> AppResult { + let cap = config.lru_default_capacity()?; + let ttl = Duration::from_secs(config.lru_default_ttl_secs()?); + let l2 = AppRedis::from_config(config)?; + let key_prefix = config.redis_key_prefix()?; + Ok(Self { + l1: LruTtlCache::new(cap, ttl), + l2, + key_prefix, + default_ttl: ttl, + }) + } + + pub fn get(&self, key: &str) -> Option { + if let Some(json) = self.l1.get(&key.to_string()) { + return serde_json::from_str(&json).ok(); + } + + let full_key = self.full_key(key); + let mut conn = self.l2.get_connection().ok()?; + let json: String = Cmd::new() + .arg("GET") + .arg(&full_key) + .query::>(&mut *conn.inner_mut()) + .ok()??; + + let value: T = serde_json::from_str(&json).ok()?; + self.l1.insert(key.to_string(), json); + Some(value) + } + + pub fn set(&self, key: &str, value: &T, ttl: Option) -> AppResult<()> { + let json = serde_json::to_string(value)?; + let full_key = self.full_key(key); + let ttl_duration = ttl.unwrap_or(self.default_ttl); + let ttl_secs = ttl_duration.as_secs() as usize; + let mut conn = self.l2.get_connection()?; + Cmd::new() + .arg("SETEX") + .arg(&full_key) + .arg(ttl_secs) + .arg(&json) + .query::<()>(&mut *conn.inner_mut())?; + self.l1.insert_with_ttl(key.to_string(), json, ttl_duration); + Ok(()) + } + + pub fn delete(&self, key: &str) -> AppResult<()> { + self.l1.remove(&key.to_string()); + let full_key = self.full_key(key); + let mut conn = self.l2.get_connection()?; + Cmd::new() + .arg("DEL") + .arg(&full_key) + .query::<()>(&mut *conn.inner_mut())?; + Ok(()) + } + + pub fn exists(&self, key: &str) -> bool { + if self.l1.get(&key.to_string()).is_some() { + return true; + } + let full_key = self.full_key(key); + if let Ok(mut conn) = self.l2.get_connection() { + return Cmd::new() + .arg("EXISTS") + .arg(&full_key) + .query(&mut *conn.inner_mut()) + .unwrap_or(false); + } + false + } + + fn full_key(&self, key: &str) -> String { + format!("{}{}", self.key_prefix, key) + } +} diff --git a/cache/redis.rs b/cache/redis.rs new file mode 100644 index 0000000..e022d46 --- /dev/null +++ b/cache/redis.rs @@ -0,0 +1,117 @@ +use crate::config::AppConfig; +use crate::error::AppError; +use crate::error::AppResult; +use r2d2::Pool; +use redis::cluster::ClusterClient; +use redis::{Client, ConnectionLike, RedisError}; +use std::time::Duration; + +#[derive(Clone)] +enum RedisBackend { + Single(Pool), + Cluster(Pool), +} + +#[derive(Clone)] +pub struct AppRedis { + backend: RedisBackend, +} + +impl AppRedis { + pub fn from_config(config: &AppConfig) -> AppResult { + let backend = if config.redis_cluster_enabled()? { + let nodes = config.redis_cluster_nodes()?; + let cluster_client = + ClusterClient::new(nodes.iter().map(|s| s.as_str()).collect::>())?; + let pool = Self::build_pool(config, cluster_client)?; + RedisBackend::Cluster(pool) + } else { + let url = config + .redis_url()? + .ok_or_else(|| AppError::Config("APP_REDIS_URL is not set".into()))?; + let client = Client::open(url.as_str())?; + let pool = Self::build_pool(config, client)?; + RedisBackend::Single(pool) + }; + + Ok(Self { backend }) + } + + fn build_pool(config: &AppConfig, manager: M) -> AppResult> { + let max_conn = config.redis_max_connections()?; + let min_conn = config.redis_min_connections()?; + let idle_timeout = config.redis_idle_timeout()?; + let conn_timeout = config.redis_connection_timeout()?; + + Ok(r2d2::Builder::new() + .max_size(max_conn) + .min_idle(Some(min_conn)) + .idle_timeout(Some(Duration::from_secs(idle_timeout))) + .connection_timeout(Duration::from_secs(conn_timeout)) + .build(manager)?) + } + + pub fn get_connection(&self) -> Result { + match &self.backend { + RedisBackend::Single(pool) => pool.get().map(PooledRedisConnection::Single), + RedisBackend::Cluster(pool) => pool.get().map(PooledRedisConnection::Cluster), + } + } +} + +#[allow(clippy::large_enum_variant)] +pub enum PooledRedisConnection { + Single(r2d2::PooledConnection), + Cluster(r2d2::PooledConnection), +} + +impl PooledRedisConnection { + pub fn inner_mut(&mut self) -> &mut dyn ConnectionLike { + match self { + PooledRedisConnection::Single(conn) => conn, + PooledRedisConnection::Cluster(conn) => conn, + } + } +} + +impl ConnectionLike for PooledRedisConnection { + fn req_packed_command(&mut self, cmd: &[u8]) -> Result { + match self { + PooledRedisConnection::Single(conn) => conn.req_packed_command(cmd), + PooledRedisConnection::Cluster(conn) => conn.req_packed_command(cmd), + } + } + + fn req_packed_commands( + &mut self, + cmd: &[u8], + offset: usize, + count: usize, + ) -> Result, RedisError> { + match self { + PooledRedisConnection::Single(conn) => conn.req_packed_commands(cmd, offset, count), + PooledRedisConnection::Cluster(conn) => conn.req_packed_commands(cmd, offset, count), + } + } + + fn get_db(&self) -> i64 { + match self { + PooledRedisConnection::Single(conn) => conn.get_db(), + PooledRedisConnection::Cluster(conn) => conn.get_db(), + } + } + + fn check_connection(&mut self) -> bool { + match self { + PooledRedisConnection::Single(conn) => conn.check_connection(), + PooledRedisConnection::Cluster(conn) => conn.check_connection(), + } + } + + fn is_open(&self) -> bool { + match self { + PooledRedisConnection::Single(conn) => conn.is_open(), + PooledRedisConnection::Cluster(conn) => conn.is_open(), + } + } +} diff --git a/config/aiprovider.rs b/config/aiprovider.rs new file mode 100644 index 0000000..64ee497 --- /dev/null +++ b/config/aiprovider.rs @@ -0,0 +1,12 @@ +use crate::config::AppConfig; +use crate::error::AppResult; + +impl AppConfig { + pub fn ai_provider_api_key(&self) -> AppResult> { + self.get_env::("APP_AI_PROVIDER_API_KEY") + } + + pub fn ai_provider_url(&self) -> AppResult> { + self.get_env::("APP_AI_PROVIDER_URL") + } +} diff --git a/config/app.rs b/config/app.rs new file mode 100644 index 0000000..6d68062 --- /dev/null +++ b/config/app.rs @@ -0,0 +1,8 @@ +use crate::config::AppConfig; +use crate::error::AppResult; + +impl AppConfig { + pub fn app_url(&self) -> AppResult { + self.get_env_or::("APP_URL", "http://localhost:8000".into()) + } +} diff --git a/config/channelaiprovider.rs b/config/channelaiprovider.rs new file mode 100644 index 0000000..8a68de7 --- /dev/null +++ b/config/channelaiprovider.rs @@ -0,0 +1,32 @@ +use crate::config::AppConfig; +use crate::error::AppResult; + +impl AppConfig { + pub fn channel_ai_provider_api_key(&self) -> AppResult> { + self.get_env::("APP_CHANNEL_AI_PROVIDER_API_KEY") + } + + pub fn channel_ai_provider_url(&self) -> AppResult> { + self.get_env::("APP_CHANNEL_AI_PROVIDER_URL") + } + + pub fn channel_ai_provider_model(&self) -> AppResult { + self.get_env_or("APP_CHANNEL_AI_PROVIDER_MODEL", "gpt-4o".to_string()) + } + + pub fn channel_ai_provider_temperature(&self) -> AppResult { + self.get_env_or("APP_CHANNEL_AI_PROVIDER_TEMPERATURE", 0.7) + } + + pub fn channel_ai_provider_max_tokens(&self) -> AppResult { + self.get_env_or("APP_CHANNEL_AI_PROVIDER_MAX_TOKENS", 4096) + } + + pub fn channel_ai_provider_top_p(&self) -> AppResult { + self.get_env_or("APP_CHANNEL_AI_PROVIDER_TOP_P", 1.0) + } + + pub fn channel_ai_provider_timeout(&self) -> AppResult { + self.get_env_or("APP_CHANNEL_AI_PROVIDER_TIMEOUT", 60) + } +} diff --git a/config/database.rs b/config/database.rs new file mode 100644 index 0000000..6d0a184 --- /dev/null +++ b/config/database.rs @@ -0,0 +1,87 @@ +use crate::config::AppConfig; +use crate::error::{AppError, AppResult}; + +impl AppConfig { + pub fn database_url(&self) -> AppResult { + if let Some(url) = self.get_env::("APP_DATABASE_URL")? { + return Ok(url); + } + if let Some(url) = self.get_env::("DATABASE_URL")? { + return Ok(url); + } + Err(AppError::Config( + "Neither APP_DATABASE_URL nor DATABASE_URL is set".into(), + )) + } + + pub fn database_max_connections(&self) -> AppResult { + self.get_env_or("APP_DATABASE_MAX_CONNECTIONS", 10) + } + + pub fn database_min_connections(&self) -> AppResult { + self.get_env_or("APP_DATABASE_MIN_CONNECTIONS", 2) + } + + pub fn database_idle_timeout(&self) -> AppResult { + self.get_env_or("APP_DATABASE_IDLE_TIMEOUT", 600) + } + + pub fn database_max_lifetime(&self) -> AppResult { + self.get_env_or("APP_DATABASE_MAX_LIFETIME", 3600) + } + + pub fn database_connection_timeout(&self) -> AppResult { + self.get_env_or("APP_DATABASE_CONNECTION_TIMEOUT", 8) + } + + pub fn database_schema_search_path(&self) -> AppResult { + self.get_env_or("APP_DATABASE_SCHEMA_SEARCH_PATH", "public".to_string()) + } + + pub fn database_read_write_split(&self) -> AppResult { + self.get_env_or("APP_DATABASE_READ_WRITE_SPLIT", false) + } + + pub fn database_read_replicas(&self) -> AppResult> { + match self.get_env::("APP_DATABASE_REPLICAS")? { + Some(s) if !s.is_empty() => Ok(s + .split(',') + .map(|u| u.trim().to_string()) + .filter(|u| !u.is_empty()) + .collect()), + _ => Ok(Vec::new()), + } + } + + pub fn database_replica_max_connections(&self) -> AppResult { + self.get_env_or("APP_DATABASE_REPLICA_MAX_CONNECTIONS", 10) + } + + pub fn database_replica_min_connections(&self) -> AppResult { + self.get_env_or("APP_DATABASE_REPLICA_MIN_CONNECTIONS", 2) + } + + pub fn database_replica_idle_timeout(&self) -> AppResult { + self.get_env_or("APP_DATABASE_REPLICA_IDLE_TIMEOUT", 600) + } + + pub fn database_replica_max_lifetime(&self) -> AppResult { + self.get_env_or("APP_DATABASE_REPLICA_MAX_LIFETIME", 3600) + } + + pub fn database_replica_connection_timeout(&self) -> AppResult { + self.get_env_or("APP_DATABASE_REPLICA_CONNECTION_TIMEOUT", 8) + } + + pub fn database_health_check_interval(&self) -> AppResult { + self.get_env_or("APP_DATABASE_HEALTH_CHECK_INTERVAL", 30) + } + + pub fn database_retry_attempts(&self) -> AppResult { + self.get_env_or("APP_DATABASE_RETRY_ATTEMPTS", 3) + } + + pub fn database_retry_delay(&self) -> AppResult { + self.get_env_or("APP_DATABASE_RETRY_DELAY", 5) + } +} diff --git a/config/embedaiprovider.rs b/config/embedaiprovider.rs new file mode 100644 index 0000000..47658bd --- /dev/null +++ b/config/embedaiprovider.rs @@ -0,0 +1,27 @@ +use crate::config::AppConfig; +use crate::error::AppResult; + +impl AppConfig { + pub fn embed_ai_provider_api_key(&self) -> AppResult> { + self.get_env::("APP_EMBED_AI_PROVIDER_API_KEY") + } + + pub fn embed_ai_provider_url(&self) -> AppResult> { + self.get_env::("APP_EMBED_AI_PROVIDER_URL") + } + + pub fn embed_ai_provider_model(&self) -> AppResult { + self.get_env_or( + "APP_EMBED_AI_PROVIDER_MODEL", + "text-embedding-3-small".to_string(), + ) + } + + pub fn embed_ai_provider_dimensions(&self) -> AppResult { + self.get_env_or("APP_EMBED_AI_PROVIDER_DIMENSIONS", 1536) + } + + pub fn embed_ai_provider_timeout(&self) -> AppResult { + self.get_env_or("APP_EMBED_AI_PROVIDER_TIMEOUT", 30) + } +} diff --git a/config/etcd.rs b/config/etcd.rs new file mode 100644 index 0000000..c2c1996 --- /dev/null +++ b/config/etcd.rs @@ -0,0 +1,59 @@ +use crate::config::AppConfig; +use crate::error::AppResult; + +impl AppConfig { + pub fn etcd_endpoints(&self) -> AppResult> { + match self.get_env::("APP_ETCD_ENDPOINTS")? { + Some(s) if !s.is_empty() => Ok(s + .split(',') + .map(|u| u.trim().to_string()) + .filter(|u| !u.is_empty()) + .collect()), + _ => Ok(vec!["http://localhost:2379".to_string()]), + } + } + + pub fn etcd_username(&self) -> AppResult> { + self.get_env::("APP_ETCD_USERNAME") + } + + pub fn etcd_password(&self) -> AppResult> { + self.get_env::("APP_ETCD_PASSWORD") + } + + pub fn etcd_ca_cert_path(&self) -> AppResult> { + self.get_env::("APP_ETCD_CA_CERT_PATH") + } + + pub fn etcd_client_cert_path(&self) -> AppResult> { + self.get_env::("APP_ETCD_CLIENT_CERT_PATH") + } + + pub fn etcd_client_key_path(&self) -> AppResult> { + self.get_env::("APP_ETCD_CLIENT_KEY_PATH") + } + + pub fn etcd_key_prefix(&self) -> AppResult { + self.get_env_or("APP_ETCD_KEY_PREFIX", "/appks/".to_string()) + } + + pub fn etcd_connect_timeout(&self) -> AppResult { + self.get_env_or("APP_ETCD_CONNECT_TIMEOUT", 5) + } + + pub fn etcd_request_timeout(&self) -> AppResult { + self.get_env_or("APP_ETCD_REQUEST_TIMEOUT", 10) + } + + pub fn etcd_keep_alive_interval(&self) -> AppResult { + self.get_env_or("APP_ETCD_KEEP_ALIVE_INTERVAL", 10) + } + + pub fn etcd_lease_ttl(&self) -> AppResult { + self.get_env_or("APP_ETCD_LEASE_TTL", 15) + } + + pub fn etcd_max_retries(&self) -> AppResult { + self.get_env_or("APP_ETCD_MAX_RETRIES", 3) + } +} diff --git a/config/lru.rs b/config/lru.rs new file mode 100644 index 0000000..994cd44 --- /dev/null +++ b/config/lru.rs @@ -0,0 +1,16 @@ +use crate::config::AppConfig; +use crate::error::AppResult; + +impl AppConfig { + pub fn lru_default_capacity(&self) -> AppResult { + self.get_env_or("APP_LRU_DEFAULT_CAPACITY", 1000) + } + + pub fn lru_default_ttl_secs(&self) -> AppResult { + self.get_env_or("APP_LRU_DEFAULT_TTL_SECS", 300) + } + + pub fn lru_cleanup_interval_secs(&self) -> AppResult { + self.get_env_or("APP_LRU_CLEANUP_INTERVAL_SECS", 60) + } +} diff --git a/config/mod.rs b/config/mod.rs new file mode 100644 index 0000000..4d3f510 --- /dev/null +++ b/config/mod.rs @@ -0,0 +1,90 @@ +use crate::error::{AppError, AppResult}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::str::FromStr; +use tokio::sync::OnceCell; + +pub static GLOBAL_CONFIG: OnceCell = OnceCell::const_new(); + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AppConfig { + pub env: HashMap, +} + +impl AppConfig { + pub fn main_domain(&self) -> AppResult { + self.get_env::("APP_MAIN_DOMAIN")? + .filter(|s| !s.is_empty()) + .ok_or_else(|| AppError::Config("APP_MAIN_DOMAIN is not set".into())) + } + + pub const ENV_FILES: &'static [&'static str] = &[ + ".env", + ".env.local", + ".env.development", + ".env.development.local", + ".env.test", + ".env.test.local", + ".env.production", + ".env.production.local", + ]; + pub fn get_env(&self, key: &str) -> AppResult> + where + ::Err: std::fmt::Display, + { + match self.env.get(key) { + Some(v) if !v.is_empty() => Ok(Some( + v.parse::().map_err(|e| AppError::Parse(e.to_string()))?, + )), + Some(_) => Ok(None), + None => Ok(None), + } + } + + pub fn get_env_or(&self, key: &str, default: T) -> AppResult + where + ::Err: std::fmt::Display, + { + Ok(self.get_env(key)?.unwrap_or(default)) + } + + pub fn load() -> AppConfig { + let mut env = HashMap::new(); + for env_file in AppConfig::ENV_FILES { + if let Err(e) = dotenvy::from_path(env_file) { + tracing::debug!(file = %env_file, error = %e, "dotenv load skipped"); + } + if let Ok(env_file_content) = std::fs::read_to_string(env_file) { + for line in env_file_content.lines() { + if let Some((key, value)) = line.split_once('=') { + env.insert(key.to_string(), value.to_string()); + } + } + } + } + env = env.into_iter().chain(std::env::vars()).collect(); + let this = AppConfig { env }; + if let Some(config) = GLOBAL_CONFIG.get() { + config.clone() + } else { + let _ = GLOBAL_CONFIG.set(this); + GLOBAL_CONFIG + .get() + .expect("global config should be set after load") + .clone() + } + } +} + +pub mod aiprovider; +pub mod app; +pub mod channelaiprovider; +pub mod database; +pub mod embedaiprovider; +pub mod etcd; +pub mod lru; +pub mod nats; +pub mod qdrant; +pub mod redis; +pub mod rpc; +pub mod s3; diff --git a/config/nats.rs b/config/nats.rs new file mode 100644 index 0000000..fd37722 --- /dev/null +++ b/config/nats.rs @@ -0,0 +1,54 @@ +use crate::config::AppConfig; +use crate::error::{AppError, AppResult}; + +impl AppConfig { + pub fn nats_url(&self) -> AppResult { + self.get_env::("APP_NATS_URL")? + .filter(|s| !s.is_empty()) + .ok_or_else(|| AppError::Config("APP_NATS_URL is not set".into())) + } + + pub fn nats_username(&self) -> AppResult> { + self.get_env::("APP_NATS_USERNAME") + } + + pub fn nats_password(&self) -> AppResult> { + self.get_env::("APP_NATS_PASSWORD") + } + + pub fn nats_token(&self) -> AppResult> { + self.get_env::("APP_NATS_TOKEN") + } + + pub fn nats_tls_enabled(&self) -> AppResult { + self.get_env_or("APP_NATS_TLS_ENABLED", false) + } + + pub fn nats_connection_timeout_secs(&self) -> AppResult { + self.get_env_or("APP_NATS_CONNECTION_TIMEOUT", 5) + } + + pub fn nats_ping_interval_secs(&self) -> AppResult { + self.get_env_or("APP_NATS_PING_INTERVAL", 20) + } + + pub fn nats_reconnect_delay_secs(&self) -> AppResult { + self.get_env_or("APP_NATS_RECONNECT_DELAY", 2) + } + + pub fn nats_max_reconnects(&self) -> AppResult { + self.get_env_or("APP_NATS_MAX_RECONNECTS", 60usize) + } + + pub fn nats_stream_prefix(&self) -> AppResult { + self.get_env_or("APP_NATS_STREAM_PREFIX", "APPKS".to_string()) + } + + pub fn nats_default_ack_wait_secs(&self) -> AppResult { + self.get_env_or("APP_NATS_ACK_WAIT_SECS", 30) + } + + pub fn nats_default_max_deliver(&self) -> AppResult { + self.get_env_or("APP_NATS_MAX_DELIVER", 5i64) + } +} diff --git a/config/qdrant.rs b/config/qdrant.rs new file mode 100644 index 0000000..1142048 --- /dev/null +++ b/config/qdrant.rs @@ -0,0 +1,63 @@ +use crate::config::AppConfig; +use crate::error::AppResult; + +impl AppConfig { + pub fn qdrant_url(&self) -> AppResult> { + self.get_env::("APP_QDRANT_URL") + } + + pub fn qdrant_cluster_nodes(&self) -> AppResult> { + match self.get_env::("APP_QDRANT_CLUSTER_NODES")? { + Some(s) if !s.is_empty() => Ok(s + .split(',') + .map(|u| u.trim().to_string()) + .filter(|u| !u.is_empty()) + .collect()), + _ => Ok(Vec::new()), + } + } + + pub fn qdrant_api_key(&self) -> AppResult> { + self.get_env::("APP_QDRANT_API_KEY") + } + + pub fn qdrant_collection(&self) -> AppResult> { + self.get_env::("APP_QDRANT_COLLECTION") + } + + pub fn qdrant_vector_size(&self) -> AppResult { + self.get_env_or("APP_QDRANT_VECTOR_SIZE", 1536) + } + + pub fn qdrant_distance(&self) -> AppResult { + self.get_env_or("APP_QDRANT_DISTANCE", "Cosine".to_string()) + } + + pub fn qdrant_max_connections(&self) -> AppResult { + self.get_env_or("APP_QDRANT_MAX_CONNECTIONS", 10) + } + + pub fn qdrant_idle_timeout(&self) -> AppResult { + self.get_env_or("APP_QDRANT_IDLE_TIMEOUT", 300) + } + + pub fn qdrant_connection_timeout(&self) -> AppResult { + self.get_env_or("APP_QDRANT_CONNECTION_TIMEOUT", 10) + } + + pub fn qdrant_max_retries(&self) -> AppResult { + self.get_env_or("APP_QDRANT_MAX_RETRIES", 3) + } + + pub fn qdrant_tls_enabled(&self) -> AppResult { + self.get_env_or("APP_QDRANT_TLS_ENABLED", true) + } + + pub fn qdrant_search_limit(&self) -> AppResult { + self.get_env_or("APP_QDRANT_SEARCH_LIMIT", 10) + } + + pub fn qdrant_score_threshold(&self) -> AppResult { + self.get_env_or("APP_QDRANT_SCORE_THRESHOLD", 0.7) + } +} diff --git a/config/redis.rs b/config/redis.rs new file mode 100644 index 0000000..d64062a --- /dev/null +++ b/config/redis.rs @@ -0,0 +1,87 @@ +use crate::config::AppConfig; +use crate::error::{AppError, AppResult}; + +impl AppConfig { + pub fn redis_url(&self) -> AppResult> { + self.get_env::("APP_REDIS_URL") + } + + pub fn redis_cluster_enabled(&self) -> AppResult { + self.get_env_or("APP_REDIS_CLUSTER_ENABLED", false) + } + + pub fn redis_cluster_nodes(&self) -> AppResult> { + match self.get_env::("APP_REDIS_CLUSTER_NODES")? { + Some(s) if !s.is_empty() => Ok(s + .split(',') + .map(|u| u.trim().to_string()) + .filter(|u| !u.is_empty()) + .collect()), + _ => Ok(Vec::new()), + } + } + + pub fn redis_read_from_replicas(&self) -> AppResult { + self.get_env_or("APP_REDIS_READ_FROM_REPLICAS", false) + } + + pub fn redis_username(&self) -> AppResult> { + self.get_env::("APP_REDIS_USERNAME") + } + + pub fn redis_password(&self) -> AppResult> { + self.get_env::("APP_REDIS_PASSWORD") + } + + pub fn redis_database(&self) -> AppResult { + self.get_env_or("APP_REDIS_DATABASE", 0u8) + } + + pub fn redis_max_connections(&self) -> AppResult { + self.get_env_or("APP_REDIS_MAX_CONNECTIONS", 20) + } + + pub fn redis_min_connections(&self) -> AppResult { + self.get_env_or("APP_REDIS_MIN_CONNECTIONS", 2) + } + + pub fn redis_idle_timeout(&self) -> AppResult { + self.get_env_or("APP_REDIS_IDLE_TIMEOUT", 300) + } + + pub fn redis_connection_timeout(&self) -> AppResult { + self.get_env_or("APP_REDIS_CONNECTION_TIMEOUT", 5) + } + + pub fn redis_max_retries(&self) -> AppResult { + self.get_env_or("APP_REDIS_MAX_RETRIES", 3) + } + + pub fn redis_retry_delay_ms(&self) -> AppResult { + self.get_env_or("APP_REDIS_RETRY_DELAY_MS", 100) + } + + pub fn redis_tls_enabled(&self) -> AppResult { + self.get_env_or("APP_REDIS_TLS_ENABLED", false) + } + + pub fn redis_key_prefix(&self) -> AppResult { + self.get_env_or("APP_REDIS_KEY_PREFIX", "".to_string()) + } + + pub fn redis_validate(&self) -> AppResult<()> { + if self.redis_cluster_enabled()? { + let nodes = self.redis_cluster_nodes()?; + if nodes.is_empty() { + return Err(AppError::Config( + "Redis cluster enabled but APP_REDIS_CLUSTER_NODES is empty".into(), + )); + } + } else if self.redis_url()?.is_none() { + return Err(AppError::Config( + "Redis cluster disabled but APP_REDIS_URL is not set".into(), + )); + } + Ok(()) + } +} diff --git a/config/rpc.rs b/config/rpc.rs new file mode 100644 index 0000000..516aafa --- /dev/null +++ b/config/rpc.rs @@ -0,0 +1,30 @@ +use crate::config::AppConfig; +use crate::error::AppResult; + +impl AppConfig { + pub fn rpc_self_host(&self) -> AppResult { + self.get_env_or("APP_RPC_SELF_HOST", "0.0.0.0".to_string()) + } + + pub fn rpc_self_port(&self) -> AppResult { + self.get_env_or("APP_RPC_SELF_PORT", 50050u16) + } + + pub fn rpc_self_listen_addr(&self) -> AppResult { + let host = self.rpc_self_host()?; + let port = self.rpc_self_port()?; + Ok(format!("{host}:{port}")) + } + + pub fn rpc_self_reflection(&self) -> AppResult { + self.get_env_or("APP_RPC_SELF_REFLECTION", false) + } + + pub fn rpc_self_service_name(&self) -> AppResult { + self.get_env_or("APP_RPC_SELF_SERVICE_NAME", "appks".to_string()) + } + + pub fn rpc_default_timeout_secs(&self) -> AppResult { + self.get_env_or("APP_RPC_DEFAULT_TIMEOUT_SECS", 10) + } +} diff --git a/config/s3.rs b/config/s3.rs new file mode 100644 index 0000000..cafb9a2 --- /dev/null +++ b/config/s3.rs @@ -0,0 +1,64 @@ +use crate::config::AppConfig; +use crate::error::AppResult; + +impl AppConfig { + pub fn s3_endpoint(&self) -> AppResult> { + self.get_env::("APP_S3_ENDPOINT") + } + + pub fn s3_region(&self) -> AppResult { + self.get_env_or("APP_S3_REGION", "us-east-1".to_string()) + } + + pub fn s3_access_key(&self) -> AppResult> { + self.get_env::("APP_S3_ACCESS_KEY") + } + + pub fn s3_secret_key(&self) -> AppResult> { + self.get_env::("APP_S3_SECRET_KEY") + } + + pub fn s3_bucket(&self) -> AppResult> { + self.get_env::("APP_S3_BUCKET") + } + + pub fn s3_path_style(&self) -> AppResult { + self.get_env_or("APP_S3_PATH_STYLE", false) + } + + pub fn s3_public_url(&self) -> AppResult> { + self.get_env::("APP_S3_PUBLIC_URL") + } + + pub fn s3_max_connections(&self) -> AppResult { + self.get_env_or("APP_S3_MAX_CONNECTIONS", 50) + } + + pub fn s3_idle_timeout(&self) -> AppResult { + self.get_env_or("APP_S3_IDLE_TIMEOUT", 90) + } + + pub fn s3_connection_timeout(&self) -> AppResult { + self.get_env_or("APP_S3_CONNECTION_TIMEOUT", 10) + } + + pub fn s3_max_retries(&self) -> AppResult { + self.get_env_or("APP_S3_MAX_RETRIES", 3) + } + + pub fn s3_upload_part_size(&self) -> AppResult { + self.get_env_or("APP_S3_UPLOAD_PART_SIZE", 8 * 1024 * 1024) + } + + pub fn s3_max_upload_size(&self) -> AppResult { + self.get_env_or("APP_S3_MAX_UPLOAD_SIZE", 100 * 1024 * 1024) + } + + pub fn s3_presigned_url_expiry(&self) -> AppResult { + self.get_env_or("APP_S3_PRESIGNED_URL_EXPIRY", 3600) + } + + pub fn s3_force_path_style(&self) -> AppResult { + self.get_env_or("APP_S3_FORCE_PATH_STYLE", false) + } +} diff --git a/error.rs b/error.rs new file mode 100644 index 0000000..a5019ab --- /dev/null +++ b/error.rs @@ -0,0 +1,105 @@ +use thiserror::Error; + +pub type AppResult = Result; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("config error: {0}")] + Config(String), + + #[error("database error: {0}")] + Database(#[from] sqlx::Error), + + #[error("redis error: {0}")] + Redis(#[from] redis::RedisError), + + #[error("r2d2 error: {0}")] + R2d2(#[from] r2d2::Error), + + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("storage error: {0}")] + Storage(#[from] object_store::Error), + + #[error("parse error: {0}")] + Parse(String), + + #[error("user not found")] + UserNotFound, + + #[error("password too weak")] + PasswordTooWeak, + + #[error("password hash error: {0}")] + PasswordHashError(String), + + #[error("invalid password")] + InvalidPassword, + + #[error("account already exists")] + AccountAlreadyExists, + + #[error("captcha error")] + CaptchaError, + + #[error("rsa key generation failed")] + RsaGenerationError, + + #[error("rsa decode error")] + RsaDecodeError, + + #[error("invalid two-factor code")] + InvalidTwoFactorCode, + + #[error("two-factor authentication required")] + TwoFactorRequired, + + #[error("two-factor already enabled")] + TwoFactorAlreadyEnabled, + + #[error("two-factor not set up")] + TwoFactorNotSetup, + + #[error("two-factor not enabled")] + TwoFactorNotEnabled, + + #[error("invalid reset token")] + InvalidResetToken, + + #[error("reset token expired")] + ResetTokenExpired, + + #[error("email already exists")] + EmailExists, + + #[error("invalid email code")] + InvalidEmailCode, + + #[error("unauthorized")] + Unauthorized, + + #[error("forbidden: {0}")] + Forbidden(String), + + #[error("bad request: {0}")] + BadRequest(String), + + #[error("conflict: {0}")] + Conflict(String), + + #[error("quota exceeded: {0}")] + QuotaExceeded(String), + + #[error("not found: {0}")] + NotFound(String), + + #[error("internal server error: {0}")] + InternalServerError(String), + + #[error("transaction error")] + TxnError, +} diff --git a/etcd/discovery.rs b/etcd/discovery.rs new file mode 100644 index 0000000..e63771c --- /dev/null +++ b/etcd/discovery.rs @@ -0,0 +1,160 @@ +use etcd_client::{GetOptions, WatchOptions}; +use tokio_stream::StreamExt; +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; +use crate::pb::{EmailClient, RepoClient}; + +use super::types::ServiceInstance; +use super::{EtcdRegistry, EtcdRegistryInner}; + +impl EtcdRegistry { + pub async fn start_discovery(&self) -> AppResult<()> { + self.load_initial("git").await?; + self.load_initial("mail").await?; + self.spawn_watch("git"); + self.spawn_watch("mail"); + Ok(()) + } + + async fn load_initial(&self, service: &str) -> AppResult<()> { + let prefix = self.service_prefix(service); + let resp = { + let mut client = self.inner.client.lock().await; + client + .get(prefix.as_str(), Some(GetOptions::new().with_prefix())) + .await + .map_err(|e| AppError::Config(format!("etcd get {prefix} failed: {e}")))? + }; + + for kv in resp.kvs() { + let key = kv.key_str().unwrap_or_default(); + let value = kv.value_str().unwrap_or_default(); + if let Ok(instance) = serde_json::from_str::(value) { + Self::upsert_instance(&self.inner, service, key, &instance); + } + } + + tracing::info!( + service = service, + prefix = prefix.as_str(), + "etcd initial discovery complete" + ); + Ok(()) + } + + fn spawn_watch(&self, service: &str) { + let prefix = self.service_prefix(service); + let inner = self.inner.clone(); + let service = service.to_string(); + + tokio::spawn(async move { + loop { + match Self::watch_loop(&inner, &prefix, &service).await { + Ok(()) => break, + Err(e) => { + tracing::warn!( + service = service.as_str(), + error = %e, + "etcd watch disconnected, retrying in 3s" + ); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } + } + } + }); + } + + async fn watch_loop(inner: &EtcdRegistryInner, prefix: &str, service: &str) -> AppResult<()> { + let (mut watcher, mut stream) = { + let mut client = inner.client.lock().await; + client + .watch(prefix, Some(WatchOptions::new().with_prefix())) + .await + .map_err(|e| AppError::Config(format!("etcd watch {prefix} failed: {e}")))? + }; + + let _keep = &mut watcher; + + while let Some(resp) = stream.next().await { + let resp = + resp.map_err(|e| AppError::Config(format!("etcd watch stream error: {e}")))?; + + for event in resp.events() { + let Some(kv) = event.kv() else { continue }; + let key = kv.key_str().unwrap_or_default(); + + match event.event_type() { + etcd_client::EventType::Put => { + let value = kv.value_str().unwrap_or_default(); + if let Ok(instance) = serde_json::from_str::(value) { + Self::upsert_instance(inner, service, key, &instance); + tracing::info!(service = service, key = key, "etcd service upserted"); + } + } + etcd_client::EventType::Delete => { + Self::remove_instance(inner, service, key); + tracing::info!(service = service, key = key, "etcd service removed"); + } + } + } + } + Ok(()) + } + + pub(crate) fn service_prefix(&self, service: &str) -> String { + format!("{}services/{service}/", self.inner.key_prefix) + } + + fn extract_id_from_key(key: &str) -> Option { + key.rsplit('/').next()?.parse().ok() + } + + fn upsert_instance( + inner: &EtcdRegistryInner, + service: &str, + key: &str, + instance: &ServiceInstance, + ) { + let Some(node_id) = Self::extract_id_from_key(key) else { + tracing::warn!(key = key, "etcd key has no valid UUID suffix"); + return; + }; + let addr = instance.addr.clone(); + + match service { + "git" => match RepoClient::lazy_connect(&addr) { + Ok(client) => { + inner.git_nodes.insert(node_id, client); + } + Err(e) => { + tracing::error!(key = key, addr = addr.as_str(), error = %e, "git client connect failed"); + } + }, + "mail" => match EmailClient::lazy_connect(&addr) { + Ok(client) => { + inner.mail_nodes.insert(node_id, client); + } + Err(e) => { + tracing::error!(key = key, addr = addr.as_str(), error = %e, "mail client connect failed"); + } + }, + _ => {} + } + } + + fn remove_instance(inner: &EtcdRegistryInner, service: &str, key: &str) { + let Some(node_id) = Self::extract_id_from_key(key) else { + return; + }; + match service { + "git" => { + inner.git_nodes.remove(&node_id); + } + "mail" => { + inner.mail_nodes.remove(&node_id); + } + _ => {} + } + } +} diff --git a/etcd/mod.rs b/etcd/mod.rs new file mode 100644 index 0000000..6934c0c --- /dev/null +++ b/etcd/mod.rs @@ -0,0 +1,88 @@ +mod discovery; +mod register; +mod types; + +pub use types::ServiceInstance; + +use std::sync::Arc; +use std::sync::atomic::AtomicI64; + +use dashmap::DashMap; +use etcd_client::Client; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::config::AppConfig; +use crate::error::{AppError, AppResult}; +use crate::pb::{EmailClient, RepoClient}; + +#[derive(Clone)] +pub struct EtcdRegistry { + pub(crate) inner: Arc, +} + +pub(crate) struct EtcdRegistryInner { + pub client: Mutex, + pub config: AppConfig, + pub key_prefix: String, + pub git_nodes: DashMap, + pub mail_nodes: DashMap, + pub lease_id: AtomicI64, +} + +impl EtcdRegistry { + pub async fn connect(config: &AppConfig) -> AppResult { + let endpoints = config.etcd_endpoints()?; + let timeout = config.etcd_connect_timeout()?; + + let opts = etcd_client::ConnectOptions::new() + .with_connect_timeout(std::time::Duration::from_secs(timeout)); + + let client = Client::connect(&endpoints, Some(opts)) + .await + .map_err(|e| AppError::Config(format!("etcd connect failed: {e}")))?; + + if let (Some(user), Some(pass)) = (config.etcd_username()?, config.etcd_password()?) { + let auth_resp = client + .auth_client() + .authenticate(user, pass) + .await + .map_err(|e| AppError::Config(format!("etcd auth failed: {e}")))?; + let token = auth_resp.token().to_string(); + tracing::info!(token_len = token.len(), "etcd authenticated"); + } + + let key_prefix = config.etcd_key_prefix()?; + + Ok(Self { + inner: Arc::new(EtcdRegistryInner { + client: Mutex::new(client), + config: config.clone(), + key_prefix, + git_nodes: DashMap::new(), + mail_nodes: DashMap::new(), + lease_id: AtomicI64::new(0), + }), + }) + } + + pub fn get_git_client(&self, node_id: &Uuid) -> Option { + self.inner.git_nodes.get(node_id).map(|c| c.clone()) + } + + pub fn git_node_ids(&self) -> Vec { + self.inner.git_nodes.iter().map(|e| *e.key()).collect() + } + + pub fn get_email_client(&self) -> Option { + self.inner + .mail_nodes + .iter() + .next() + .map(|e| e.value().clone()) + } + + pub fn has_git_nodes(&self) -> bool { + !self.inner.git_nodes.is_empty() + } +} diff --git a/etcd/register.rs b/etcd/register.rs new file mode 100644 index 0000000..f962604 --- /dev/null +++ b/etcd/register.rs @@ -0,0 +1,113 @@ +use std::collections::HashMap; +use std::sync::atomic::Ordering; + +use etcd_client::PutOptions; +use tokio_stream::StreamExt; + +use crate::error::{AppError, AppResult}; + +use super::EtcdRegistry; +use super::types::ServiceInstance; + +impl EtcdRegistry { + pub async fn register_self(&self, service_name: &str) -> AppResult<()> { + let ttl = self.inner.config.etcd_lease_ttl()?; + let listen_addr = self.inner.config.rpc_self_listen_addr()?; + + let instance_id = uuid::Uuid::now_v7().to_string(); + let key = format!( + "{}services/{service_name}/{instance_id}", + self.inner.key_prefix + ); + + let instance = ServiceInstance { + addr: listen_addr.clone(), + metadata: HashMap::new(), + }; + let value = serde_json::to_string(&instance)?; + + let lease_resp = { + let mut client = self.inner.client.lock().await; + client + .lease_grant(ttl as i64, None) + .await + .map_err(|e| AppError::Config(format!("etcd lease_grant failed: {e}")))? + }; + let lease_id = lease_resp.id(); + self.inner.lease_id.store(lease_id, Ordering::SeqCst); + + { + let mut client = self.inner.client.lock().await; + let opts = PutOptions::new().with_lease(lease_id); + client + .put(key.clone(), value, Some(opts)) + .await + .map_err(|e| AppError::Config(format!("etcd put failed: {e}")))?; + } + + tracing::info!( + service = service_name, + addr = listen_addr.as_str(), + lease_id = lease_id, + "registered self in etcd" + ); + + self.spawn_keep_alive(lease_id, key); + + Ok(()) + } + + fn spawn_keep_alive(&self, lease_id: i64, key: String) { + let inner = self.inner.clone(); + let interval = self.inner.config.etcd_keep_alive_interval().unwrap_or(10); + + tokio::spawn(async move { + loop { + let result = { + let mut client = inner.client.lock().await; + client.lease_keep_alive(lease_id).await + }; + + match result { + Ok((_keeper, mut stream)) => { + while let Some(resp) = stream.next().await { + if let Err(e) = resp { + tracing::warn!(lease_id = lease_id, error = %e, "keep-alive stream error"); + break; + } + } + } + Err(e) => { + tracing::warn!(lease_id = lease_id, error = %e, "keep-alive failed"); + } + } + + tokio::time::sleep(std::time::Duration::from_secs(interval)).await; + + let re_grant = { + let mut client = inner.client.lock().await; + client + .lease_grant(inner.config.etcd_lease_ttl().unwrap_or(15) as i64, None) + .await + }; + + if let Ok(current) = re_grant { + let new_lease = current.id(); + inner.lease_id.store(new_lease, Ordering::SeqCst); + + let instance = ServiceInstance { + addr: inner.config.rpc_self_listen_addr().unwrap_or_default(), + metadata: HashMap::new(), + }; + + if let Ok(value) = serde_json::to_string(&instance) { + let mut client = inner.client.lock().await; + let opts = PutOptions::new().with_lease(new_lease); + let _ = client.put(key.clone(), value, Some(opts)).await; + } + tracing::info!(old = lease_id, new = new_lease, "etcd lease renewed"); + } + } + }); + } +} diff --git a/etcd/types.rs b/etcd/types.rs new file mode 100644 index 0000000..f26a6e2 --- /dev/null +++ b/etcd/types.rs @@ -0,0 +1,10 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceInstance { + pub addr: String, + #[serde(default)] + pub metadata: HashMap, +} diff --git a/immediate/bridge.rs b/immediate/bridge.rs new file mode 100644 index 0000000..3f9c6c0 --- /dev/null +++ b/immediate/bridge.rs @@ -0,0 +1,200 @@ +use std::sync::Arc; + +use futures_util::StreamExt; +use uuid::Uuid; + +use crate::queue::NatsQueue; + +use super::{ + ArticleEvent, CategoryEvent, DraftEvent, FollowEvent, MemberEvent, MessageEvent, PollEvent, + PresenceEvent, ReactionEvent, ThreadEvent, TypingEvent, WsOutbound, WsSessionManager, + WsSinkManager, +}; + +#[derive(Clone)] +pub struct NatsWsBridge { + queue: Arc, + sessions: Arc, + sinks: Arc, +} + +impl NatsWsBridge { + pub fn new( + queue: Arc, + sessions: Arc, + sinks: Arc, + ) -> Self { + Self { + queue, + sessions, + sinks, + } + } + + pub async fn run_ephemeral(self, subject: &str) { + let Ok(mut sub) = self.queue.subscribe_ephemeral(subject.to_string()).await else { + tracing::warn!(subject, "nats ws bridge subscribe failed"); + return; + }; + while let Some(msg) = sub.next().await { + self.dispatch(msg.subject.as_str(), msg.payload.as_ref(), request_id(&msg)) + .await; + } + } + + async fn dispatch(&self, subject: &str, payload: &[u8], request_id: Uuid) { + if subject.starts_with("im.message.") { + self.channel_event(payload, |data| WsOutbound::Message { request_id, data }); + } else if subject.starts_with("im.thread.") { + self.channel_event(payload, |data| WsOutbound::Thread { request_id, data }); + } else if subject.starts_with("im.member.") { + self.channel_event(payload, |data| WsOutbound::Member { request_id, data }); + } else if subject.starts_with("im.reaction.") { + self.channel_event(payload, |data| WsOutbound::Reaction { request_id, data }); + } else if subject.starts_with("im.poll.") { + self.channel_event(payload, |data| WsOutbound::Poll { request_id, data }); + } else if subject.starts_with("im.article.") { + self.channel_event(payload, |data| WsOutbound::Article { request_id, data }); + } else if subject.starts_with("im.typing.") { + self.channel_event(payload, |data| WsOutbound::Typing { request_id, data }); + } else if subject.starts_with("im.presence.") { + self.presence_event(payload, request_id); + } else if subject.starts_with("im.channel.") { + self.channel_meta_event(subject, payload, request_id); + } else if subject.starts_with("im.category.") { + self.category_event(payload, request_id); + } else if subject.starts_with("im.draft.") { + self.draft_event(payload, request_id); + } else if subject.starts_with("im.follow.") { + self.channel_event(payload, |data| WsOutbound::Follow { request_id, data }); + } + } + + fn channel_event(&self, payload: &[u8], build: F) + where + T: serde::de::DeserializeOwned + ChannelScoped, + F: Fn(T) -> WsOutbound, + { + let Ok(data) = serde_json::from_slice::(payload) else { + tracing::warn!("nats ws bridge decode channel event failed"); + return; + }; + let channel_id = data.channel_id(); + let subscribers = self.sessions.subscribers(channel_id); + let delivered = self.sinks.send_many(subscribers, build(data)); + tracing::debug!(%channel_id, delivered, "nats event forwarded to ws subscribers"); + } + + fn presence_event(&self, payload: &[u8], request_id: Uuid) { + let Ok(data) = serde_json::from_slice::(payload) else { + tracing::warn!("nats ws bridge decode presence event failed"); + return; + }; + let ids = self.sessions.user_connections(data.user_id); + let delivered = self + .sinks + .send_many(ids, WsOutbound::Presence { request_id, data }); + tracing::debug!(delivered, "nats presence forwarded to ws subscribers"); + } + + fn category_event(&self, payload: &[u8], request_id: Uuid) { + let Ok(data) = serde_json::from_slice::(payload) else { + tracing::warn!("nats ws bridge decode category event failed"); + return; + }; + let targets = self.sessions.workspace_connections(&data.workspace_name); + let delivered = self + .sinks + .send_many(targets, WsOutbound::Category { request_id, data }); + tracing::debug!(delivered, "nats category event forwarded to ws subscribers"); + } + + fn draft_event(&self, payload: &[u8], request_id: Uuid) { + let Ok(data) = serde_json::from_slice::(payload) else { + tracing::warn!("nats ws bridge decode draft event failed"); + return; + }; + let targets = self.sessions.user_connections(data.user_id); + let delivered = self + .sinks + .send_many(targets, WsOutbound::Draft { request_id, data }); + tracing::debug!(delivered, "nats draft event forwarded to ws subscribers"); + } + + fn channel_meta_event(&self, subject: &str, payload: &[u8], request_id: Uuid) { + let Ok(data) = serde_json::from_slice::(payload) else { + tracing::warn!("nats ws bridge decode channel event failed"); + return; + }; + let mut targets = data + .workspace_name + .as_deref() + .map(|workspace| self.sessions.workspace_connections(workspace)) + .unwrap_or_else(|| self.sessions.subscribers(data.channel_id)); + if targets.is_empty() + && let Some(id) = subject + .rsplit('.') + .next() + .and_then(|v| v.parse::().ok()) + { + targets = self.sessions.subscribers(id); + } + let delivered = self + .sinks + .send_many(targets, WsOutbound::Channel { request_id, data }); + tracing::debug!(delivered, "nats channel event forwarded to ws subscribers"); + } +} + +pub trait ChannelScoped { + fn channel_id(&self) -> Uuid; +} + +impl ChannelScoped for MessageEvent { + fn channel_id(&self) -> Uuid { + self.channel_id + } +} +impl ChannelScoped for ThreadEvent { + fn channel_id(&self) -> Uuid { + self.channel_id + } +} +impl ChannelScoped for MemberEvent { + fn channel_id(&self) -> Uuid { + self.channel_id + } +} +impl ChannelScoped for ReactionEvent { + fn channel_id(&self) -> Uuid { + self.channel_id + } +} +impl ChannelScoped for PollEvent { + fn channel_id(&self) -> Uuid { + self.channel_id + } +} +impl ChannelScoped for ArticleEvent { + fn channel_id(&self) -> Uuid { + self.channel_id + } +} +impl ChannelScoped for TypingEvent { + fn channel_id(&self) -> Uuid { + self.channel_id + } +} +impl ChannelScoped for FollowEvent { + fn channel_id(&self) -> Uuid { + self.channel_id + } +} + +fn request_id(msg: &async_nats::Message) -> Uuid { + msg.headers + .as_ref() + .and_then(|h| h.get("X-Request-Id")) + .and_then(|v| v.as_str().parse().ok()) + .unwrap_or_else(Uuid::nil) +} diff --git a/immediate/dedup.rs b/immediate/dedup.rs new file mode 100644 index 0000000..8ced357 --- /dev/null +++ b/immediate/dedup.rs @@ -0,0 +1,58 @@ +use uuid::Uuid; + +use crate::cache::redis::AppRedis; +use crate::error::AppResult; +use ::redis::Cmd; + +use super::redis_keys::*; + +pub struct DedupManager { + redis: AppRedis, + window_secs: u64, +} + +impl DedupManager { + pub fn new(redis: AppRedis) -> Self { + Self { + redis, + window_secs: WS_DEDUP_WINDOW_SECS, + } + } + + pub fn check_and_mark(&self, message_id: Uuid, channel_id: Uuid) -> AppResult { + let key = format!("{WS_DEDUP_PREFIX}{channel_id}:{message_id}"); + let mut conn = self.redis.get_connection()?; + let result: Option = Cmd::new() + .arg("SET") + .arg(&key) + .arg("1") + .arg("NX") + .arg("EX") + .arg(self.window_secs) + .query(&mut *conn.inner_mut()) + .map_err(crate::error::AppError::Redis)?; + Ok(result.is_some()) + } + + pub fn is_duplicate(&self, message_id: Uuid, channel_id: Uuid) -> AppResult { + let key = format!("{WS_DEDUP_PREFIX}{channel_id}:{message_id}"); + let mut conn = self.redis.get_connection()?; + let exists: bool = Cmd::new() + .arg("EXISTS") + .arg(&key) + .query(&mut *conn.inner_mut()) + .map_err(crate::error::AppError::Redis)?; + Ok(exists) + } + + pub fn clear(&self, message_id: Uuid, channel_id: Uuid) -> AppResult<()> { + let key = format!("{WS_DEDUP_PREFIX}{channel_id}:{message_id}"); + let mut conn = self.redis.get_connection()?; + Cmd::new() + .arg("DEL") + .arg(&key) + .query::<()>(&mut *conn.inner_mut()) + .map_err(crate::error::AppError::Redis)?; + Ok(()) + } +} diff --git a/immediate/envelope.rs b/immediate/envelope.rs new file mode 100644 index 0000000..e5b668a --- /dev/null +++ b/immediate/envelope.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransportEnvelope { + #[serde(default = "Uuid::now_v7")] + pub message_id: Uuid, + pub request_id: Uuid, + pub user_id: Uuid, + pub payload: T, + #[serde(default = "default_timestamp")] + pub created_at: chrono::DateTime, + #[serde(default)] + pub attempt: u8, +} + +fn default_timestamp() -> chrono::DateTime { + chrono::Utc::now() +} + +impl TransportEnvelope { + pub fn new(request_id: Uuid, user_id: Uuid, payload: T) -> Self { + Self { + message_id: Uuid::now_v7(), + request_id, + user_id, + payload, + created_at: chrono::Utc::now(), + attempt: 1, + } + } + + pub fn retry(self) -> Self { + Self { + attempt: self.attempt + 1, + ..self + } + } +} diff --git a/immediate/handler.rs b/immediate/handler.rs new file mode 100644 index 0000000..4f09dd2 --- /dev/null +++ b/immediate/handler.rs @@ -0,0 +1,447 @@ +use std::sync::Arc; + +use uuid::Uuid; + +use crate::immediate::dedup::DedupManager; +use crate::immediate::limiter::HandlerLimiter; +use crate::immediate::nats::ImNats; +use crate::immediate::outbound::*; +use crate::immediate::rate_limit::{LocalRateLimiter, RateLimiter}; +use crate::immediate::reconnect::ReconnectManager; +use crate::immediate::session::{WsSession, WsSessionManager}; +use crate::service::ImService; +use crate::service::im::messages::EditMessageParams; +use crate::service::im::messages::SendMessageParams; +use crate::service::im::presence::UpdatePresenceParams; +use crate::service::im::session::ImSession; + +use super::inbound::WsInbound; +use super::redis_keys::*; +use super::sink::WsSinkManager; + +#[allow(dead_code)] +#[derive(Clone)] +pub struct WsHandler { + nats: Arc, + manager: Arc, + sinks: Arc, + service: ImService, + dedup: Arc, + rate_limiter: Arc, + local_limiter: Arc, + handler_limiter: Arc, + reconnect: Arc, + session: Option, +} + +#[allow(dead_code)] +impl WsHandler { + pub fn new( + manager: Arc, + sinks: Arc, + service: ImService, + nats: Arc, + dedup: Arc, + rate_limiter: Arc, + reconnect: Arc, + ) -> Self { + Self { + nats, + manager, + sinks, + service, + dedup, + rate_limiter, + local_limiter: Arc::new(LocalRateLimiter::new(WS_MAX_MESSAGES_PER_SEC)), + handler_limiter: Arc::new(HandlerLimiter::new(1024)), + reconnect, + session: None, + } + } + + pub fn session(&self) -> Option<&WsSession> { + self.session.as_ref() + } + pub fn is_authenticated(&self) -> bool { + self.session.is_some() + } + + pub fn handle_disconnect(&self) { + if let Some(s) = &self.session + && let Err(e) = self.manager.unregister_connection(s) + { + tracing::warn!(conn = %s.connection_id, error = %e, "unregister failed"); + } + } + + pub async fn handle(&mut self, msg: WsInbound) -> Vec { + match msg { + WsInbound::Auth { request_id, token } => self.handle_auth(request_id, token).await, + m => { + let Some(s) = &self.session else { + return vec![WsOutbound::Error { + request_id: request_id_of(&m), + code: "not_authenticated".into(), + message: "authenticate first".into(), + }]; + }; + if !self.manager.is_deliverable(s.connection_id) { + return vec![WsOutbound::Error { + request_id: request_id_of(&m), + code: "session_not_active".into(), + message: "session is not active".into(), + }]; + } + let Ok(_permit) = self.handler_limiter.try_acquire() else { + return vec![WsOutbound::Error { + request_id: request_id_of(&m), + code: "overloaded".into(), + message: "too many inflight messages".into(), + }]; + }; + if !self.local_limiter.check() { + return vec![WsOutbound::Error { + request_id: request_id_of(&m), + code: "rate_limit_exceeded".into(), + message: "too many messages".into(), + }]; + } + match self.rate_limiter.check(s.connection_id) { + Ok(true) => {} + Ok(false) => { + return vec![WsOutbound::Error { + request_id: request_id_of(&m), + code: "rate_limit_exceeded".into(), + message: "too many messages".into(), + }]; + } + Err(e) => tracing::warn!(error = %e, "rate limit check failed"), + } + self.dispatch(s, m).await + } + } + } + + async fn dispatch(&self, session: &WsSession, msg: WsInbound) -> Vec { + match msg { + WsInbound::Heartbeat { request_id } => { + if let Err(e) = self.manager.heartbeat(session) { + tracing::warn!(user = %session.user_id, error = %e, "heartbeat failed"); + } + vec![WsOutbound::HeartbeatAck { + request_id, + timestamp_ms: chrono::Utc::now().timestamp_millis(), + }] + } + WsInbound::JoinChannel { + request_id, + channel_id, + } => match self.service.resolve_channel(channel_id).await { + Ok(channel) => match self + .service + .ensure_channel_readable(session.user_id, &channel) + .await + { + Ok(()) => { + self.manager + .subscribe_channel(session.connection_id, channel_id); + vec![] + } + Err(e) => vec![WsOutbound::Error { + request_id, + code: "join_channel_failed".into(), + message: e.to_string(), + }], + }, + Err(e) => vec![WsOutbound::Error { + request_id, + code: "join_channel_failed".into(), + message: e.to_string(), + }], + }, + WsInbound::LeaveChannel { + request_id: _, + channel_id, + } => { + self.manager + .unsubscribe_channel(session.connection_id, channel_id); + vec![] + } + WsInbound::TypingStart { + request_id, + channel_id, + thread_id, + } => { + let _ = self + .manager + .set_typing(channel_id, thread_id, session.user_id); + self.nats + .emit( + &ImNats::typing_subject(channel_id), + request_id, + &TypingEvent { + channel_id, + thread_id, + user_id: session.user_id, + }, + ) + .await; + vec![] + } + WsInbound::TypingStop { + request_id: _, + channel_id, + thread_id, + } => { + let _ = self + .manager + .clear_typing(channel_id, thread_id, session.user_id); + vec![] + } + WsInbound::MessageSend { + request_id, + channel_id, + body, + thread_id, + reply_to, + message_type, + } => { + if body.len() > WS_MAX_MESSAGE_BYTES { + return vec![WsOutbound::Error { + request_id, + code: "message_too_large".into(), + message: "message body too large".into(), + }]; + } + match self.dedup.check_and_mark(request_id, channel_id) { + Ok(true) => {} + Ok(false) => { + return vec![WsOutbound::Error { + request_id, + code: "duplicate".into(), + message: "duplicate message".into(), + }]; + } + Err(e) => tracing::warn!(error = %e, "dedup check failed"), + } + let ctx = ImSession::new(session.user_id); + let params = SendMessageParams { + body, + message_type, + thread_id, + reply_to_message_id: reply_to, + pinned: None, + attachments: None, + embeds: None, + }; + match self + .service + .message_send( + &ctx, + &session.workspace_name, + channel_id, + params, + request_id, + ) + .await + { + Ok(msg) => vec![WsOutbound::SeqAck { + request_id, + channel_id, + seq: msg.seq, + }], + Err(e) => { + if let Err(clear_err) = self.dedup.clear(request_id, channel_id) { + tracing::warn!(error = %clear_err, "dedup clear failed after message send error"); + } + vec![WsOutbound::Error { + request_id, + code: "message_send_failed".into(), + message: e.to_string(), + }] + } + } + } + WsInbound::MessageEdit { + request_id, + channel_id, + message_id, + body, + } => { + if body.len() > WS_MAX_MESSAGE_BYTES { + return vec![WsOutbound::Error { + request_id, + code: "message_too_large".into(), + message: "message body too large".into(), + }]; + } + let ctx = ImSession::new(session.user_id); + let params = EditMessageParams { body }; + match self + .service + .message_edit( + &ctx, + &session.workspace_name, + channel_id, + message_id, + params, + request_id, + ) + .await + { + Ok(_) => vec![], + Err(e) => vec![WsOutbound::Error { + request_id, + code: "message_edit_failed".into(), + message: e.to_string(), + }], + } + } + WsInbound::MessageDelete { + request_id, + channel_id, + message_id, + } => { + let ctx = ImSession::new(session.user_id); + match self + .service + .message_delete( + &ctx, + &session.workspace_name, + channel_id, + message_id, + request_id, + ) + .await + { + Ok(()) => vec![], + Err(e) => vec![WsOutbound::Error { + request_id, + code: "message_delete_failed".into(), + message: e.to_string(), + }], + } + } + WsInbound::PresenceUpdate { + request_id, + status, + custom_status_text, + custom_status_emoji, + } => { + let ctx = ImSession::new(session.user_id); + let params = UpdatePresenceParams { + status, + custom_status_text: custom_status_text.clone(), + custom_status_emoji: custom_status_emoji.clone(), + }; + match self + .service + .presence_update(&ctx, &session.workspace_name, params) + .await + { + Ok(p) => { + self.nats + .emit( + &ImNats::presence_subject(session.user_id), + request_id, + &PresenceEvent { + user_id: session.user_id, + status: p.status.to_string(), + custom_status_text, + custom_status_emoji, + }, + ) + .await; + vec![] + } + Err(e) => vec![WsOutbound::Error { + request_id, + code: "presence_update_failed".into(), + message: e.to_string(), + }], + } + } + WsInbound::ReadReceipt { + request_id, + channel_id, + last_read_message_id, + last_seq, + } => { + if let Some(seq) = last_seq + && let Err(e) = + self.reconnect + .save_read_position(session.user_id, channel_id, seq) + { + tracing::warn!(error = %e, "save read position failed"); + } + vec![WsOutbound::ReadReceiptAck { + request_id, + channel_id, + last_read_message_id, + last_seq, + }] + } + WsInbound::Auth { .. } => unreachable!(), + } + } + + fn close_replaced_connection(&self, old_id: Uuid, new_id: Uuid) { + let _ = self.sinks.send( + old_id, + WsOutbound::Error { + request_id: Uuid::nil(), + code: "session_replaced".into(), + message: format!("session replaced by {new_id}"), + }, + ); + self.sinks.detach(old_id); + if let Some(old) = self.manager.get_session(old_id) + && let Err(e) = self.manager.unregister_connection(&old) + { + tracing::warn!(conn = %old_id, error = %e, "unregister replaced connection failed"); + } + } + + async fn handle_auth(&mut self, request_id: Uuid, token: String) -> Vec { + match self.manager.redeem_token(&token) { + Ok(session) => { + match self.manager.register_connection_with_replacement(&session) { + Ok(Some(old_id)) => { + self.close_replaced_connection(old_id, session.connection_id) + } + Ok(None) => {} + Err(e) => tracing::warn!(error = %e, "register connection failed"), + } + let cid = session.connection_id; + let interval = self.manager.heartbeat_interval_secs(); + self.session = Some(session); + vec![WsOutbound::AuthOk { + request_id, + connection_id: cid, + heartbeat_interval_secs: interval, + }] + } + Err(e) => vec![WsOutbound::AuthError { + request_id, + message: e.to_string(), + }], + } + } +} + +#[allow(dead_code)] +fn request_id_of(msg: &WsInbound) -> Uuid { + match msg { + WsInbound::Auth { request_id, .. } => *request_id, + WsInbound::Heartbeat { request_id } => *request_id, + WsInbound::JoinChannel { request_id, .. } => *request_id, + WsInbound::LeaveChannel { request_id, .. } => *request_id, + WsInbound::TypingStart { request_id, .. } => *request_id, + WsInbound::TypingStop { request_id, .. } => *request_id, + WsInbound::MessageSend { request_id, .. } => *request_id, + WsInbound::MessageEdit { request_id, .. } => *request_id, + WsInbound::MessageDelete { request_id, .. } => *request_id, + WsInbound::PresenceUpdate { request_id, .. } => *request_id, + WsInbound::ReadReceipt { request_id, .. } => *request_id, + } +} diff --git a/immediate/inbound.rs b/immediate/inbound.rs new file mode 100644 index 0000000..970c794 --- /dev/null +++ b/immediate/inbound.rs @@ -0,0 +1,68 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum WsInbound { + Auth { + request_id: Uuid, + token: String, + }, + Heartbeat { + request_id: Uuid, + }, + JoinChannel { + request_id: Uuid, + channel_id: Uuid, + }, + LeaveChannel { + request_id: Uuid, + channel_id: Uuid, + }, + TypingStart { + request_id: Uuid, + channel_id: Uuid, + thread_id: Option, + }, + TypingStop { + request_id: Uuid, + channel_id: Uuid, + thread_id: Option, + }, + MessageSend { + request_id: Uuid, + channel_id: Uuid, + body: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reply_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + message_type: Option, + }, + MessageEdit { + request_id: Uuid, + channel_id: Uuid, + message_id: Uuid, + body: String, + }, + MessageDelete { + request_id: Uuid, + channel_id: Uuid, + message_id: Uuid, + }, + PresenceUpdate { + request_id: Uuid, + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + custom_status_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + custom_status_emoji: Option, + }, + ReadReceipt { + request_id: Uuid, + channel_id: Uuid, + last_read_message_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + last_seq: Option, + }, +} diff --git a/immediate/limiter.rs b/immediate/limiter.rs new file mode 100644 index 0000000..0a7a6b6 --- /dev/null +++ b/immediate/limiter.rs @@ -0,0 +1,46 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HandlerLimitError; + +#[derive(Clone)] +pub struct HandlerLimiter { + sem: Arc, + max_inflight: usize, + rejected: Arc, +} + +impl HandlerLimiter { + pub fn new(max_inflight: usize) -> Self { + Self { + sem: Arc::new(Semaphore::new(max_inflight)), + max_inflight, + rejected: Arc::new(AtomicU64::new(0)), + } + } + + pub fn try_acquire(&self) -> Result { + match self.sem.clone().try_acquire_owned() { + Ok(permit) => Ok(permit), + Err(_) => { + self.rejected.fetch_add(1, Ordering::Relaxed); + Err(HandlerLimitError) + } + } + } + + pub fn inflight(&self) -> usize { + self.max_inflight - self.sem.available_permits() + } + + pub fn available(&self) -> usize { + self.sem.available_permits() + } + + pub fn rejected_total(&self) -> u64 { + self.rejected.load(Ordering::Relaxed) + } +} diff --git a/immediate/mod.rs b/immediate/mod.rs new file mode 100644 index 0000000..ea8826e --- /dev/null +++ b/immediate/mod.rs @@ -0,0 +1,36 @@ +mod bridge; +mod dedup; +mod envelope; +mod handler; +mod inbound; +mod limiter; +mod nats; +mod outbound; +mod rate_limit; +mod reconnect; +mod redis_keys; +mod runtime; +mod seq; +mod session; +mod session_redis; +mod sink; +mod typing; + +pub use bridge::NatsWsBridge; +pub use dedup::DedupManager; +pub use envelope::TransportEnvelope; +pub use inbound::WsInbound; +pub use limiter::HandlerLimiter; +pub use nats::ImNats; +pub use outbound::{ + ArticleAction, ArticleEvent, CategoryAction, CategoryEvent, ChannelAction, ChannelEvent, + DraftAction, DraftEvent, FollowAction, FollowEvent, MemberAction, MemberEvent, MessageAction, + MessageEvent, PollAction, PollEvent, PresenceEvent, ReactionAction, ReactionEvent, + ThreadAction, ThreadEvent, TypingEvent, WsOutbound, +}; +pub use rate_limit::{LocalRateLimiter, RateLimiter}; +pub use reconnect::ReconnectManager; +pub use runtime::WsRuntime; +pub use seq::SeqAllocator; +pub use session::{WsSession, WsSessionManager, WsSessionState}; +pub use sink::{WsReceiver, WsSender, WsSinkManager}; diff --git a/immediate/nats.rs b/immediate/nats.rs new file mode 100644 index 0000000..c21f7d9 --- /dev/null +++ b/immediate/nats.rs @@ -0,0 +1,81 @@ +use std::sync::Arc; + +use serde::Serialize; +use uuid::Uuid; + +use crate::queue::NatsQueue; + +#[derive(Clone)] +pub struct ImNats { + inner: Arc, +} + +impl ImNats { + pub fn new(nats: Arc) -> Self { + Self { inner: nats } + } + + pub async fn emit(&self, subject: &str, request_id: Uuid, event: &T) { + if let Err(e) = self + .inner + .publish_with_headers( + subject, + &serde_json::to_vec(event).unwrap_or_default(), + vec![("X-Request-Id".into(), request_id.to_string())], + ) + .await + { + tracing::warn!(subject, error = %e, "nats emit failed"); + } + } + + #[inline] + pub fn channel_subject(channel_id: Uuid) -> String { + format!("im.channel.{channel_id}") + } + + #[inline] + pub fn message_subject(channel_id: Uuid) -> String { + format!("im.message.{channel_id}") + } + + #[inline] + pub fn thread_subject(channel_id: Uuid, thread_id: Uuid) -> String { + format!("im.thread.{channel_id}.{thread_id}") + } + + #[inline] + pub fn member_subject(channel_id: Uuid) -> String { + format!("im.member.{channel_id}") + } + + #[inline] + pub fn reaction_subject(channel_id: Uuid) -> String { + format!("im.reaction.{channel_id}") + } + + #[inline] + pub fn typing_subject(channel_id: Uuid) -> String { + format!("im.typing.{channel_id}") + } + + #[inline] + pub fn presence_subject(user_id: Uuid) -> String { + format!("im.presence.{user_id}") + } + + #[inline] + pub fn poll_subject(channel_id: Uuid) -> String { + format!("im.poll.{channel_id}") + } + + #[inline] + pub fn article_subject(channel_id: Uuid) -> String { + format!("im.article.{channel_id}") + } + + #[inline] + pub fn workspace_channels_subject(workspace_name: &str) -> String { + format!("im.ws_channels.{workspace_name}") + } +} diff --git a/immediate/outbound.rs b/immediate/outbound.rs new file mode 100644 index 0000000..29fd304 --- /dev/null +++ b/immediate/outbound.rs @@ -0,0 +1,256 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum WsOutbound { + AuthOk { + request_id: Uuid, + connection_id: Uuid, + heartbeat_interval_secs: u64, + }, + AuthError { + request_id: Uuid, + message: String, + }, + HeartbeatAck { + request_id: Uuid, + timestamp_ms: i64, + }, + Error { + request_id: Uuid, + code: String, + message: String, + }, + Typing { + request_id: Uuid, + data: TypingEvent, + }, + Presence { + request_id: Uuid, + data: PresenceEvent, + }, + Message { + request_id: Uuid, + data: MessageEvent, + }, + Channel { + request_id: Uuid, + data: ChannelEvent, + }, + Thread { + request_id: Uuid, + data: ThreadEvent, + }, + Member { + request_id: Uuid, + data: MemberEvent, + }, + Reaction { + request_id: Uuid, + data: ReactionEvent, + }, + Poll { + request_id: Uuid, + data: PollEvent, + }, + Article { + request_id: Uuid, + data: ArticleEvent, + }, + Category { + request_id: Uuid, + data: CategoryEvent, + }, + Draft { + request_id: Uuid, + data: DraftEvent, + }, + Follow { + request_id: Uuid, + data: FollowEvent, + }, + ReadReceiptAck { + request_id: Uuid, + channel_id: Uuid, + last_read_message_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + last_seq: Option, + }, + SeqAck { + request_id: Uuid, + channel_id: Uuid, + seq: i64, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TypingEvent { + pub channel_id: Uuid, + pub thread_id: Option, + pub user_id: Uuid, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PresenceEvent { + pub user_id: Uuid, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_status_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_status_emoji: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageEvent { + pub channel_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_id: Option, + pub message_id: Uuid, + pub author_id: Uuid, + pub action: MessageAction, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub seq: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum MessageAction { + Created, + Edited, + Deleted, + Pinned, + Unpinned, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelEvent { + pub channel_id: Uuid, + pub action: ChannelAction, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ChannelAction { + Created, + Updated, + Deleted, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThreadEvent { + pub channel_id: Uuid, + pub thread_id: Uuid, + pub action: ThreadAction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ThreadAction { + Created, + Updated, + Deleted, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemberEvent { + pub channel_id: Uuid, + pub user_id: Uuid, + pub action: MemberAction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum MemberAction { + Joined, + Left, + Kicked, + Updated, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReactionEvent { + pub channel_id: Uuid, + pub message_id: Uuid, + pub user_id: Uuid, + pub action: ReactionAction, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ReactionAction { + Added, + Removed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PollEvent { + pub channel_id: Uuid, + pub poll_id: Uuid, + pub action: PollAction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum PollAction { + Created, + Voted, + Deleted, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArticleEvent { + pub channel_id: Uuid, + pub article_id: Uuid, + pub action: ArticleAction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ArticleAction { + Created, + Updated, + Published, + Unpublished, + Deleted, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CategoryEvent { + pub workspace_name: String, + pub category_id: Uuid, + pub action: CategoryAction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum CategoryAction { + Created, + Updated, + Deleted, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DraftEvent { + pub channel_id: Uuid, + pub user_id: Uuid, + pub thread_id: Option, + pub action: DraftAction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum DraftAction { + Saved, + Deleted, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FollowEvent { + pub channel_id: Uuid, + pub follow_id: Uuid, + pub action: FollowAction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum FollowAction { + Created, + Deleted, + Retried, +} diff --git a/immediate/rate_limit.rs b/immediate/rate_limit.rs new file mode 100644 index 0000000..27bd7cc --- /dev/null +++ b/immediate/rate_limit.rs @@ -0,0 +1,102 @@ +use std::time::Instant; + +use uuid::Uuid; + +use crate::cache::redis::AppRedis; +use crate::error::AppResult; +use ::redis::Cmd; + +use super::redis_keys::*; + +pub struct RateLimiter { + redis: AppRedis, + max_per_sec: u32, +} + +impl RateLimiter { + pub fn new(redis: AppRedis) -> Self { + Self { + redis, + max_per_sec: WS_MAX_MESSAGES_PER_SEC, + } + } + + pub fn with_limit(redis: AppRedis, max_per_sec: u32) -> Self { + Self { redis, max_per_sec } + } + + pub fn check(&self, connection_id: Uuid) -> AppResult { + let key = format!("{WS_RATE_PREFIX}{connection_id}"); + let mut conn = self.redis.get_connection()?; + let count: i64 = Cmd::new() + .arg("INCR") + .arg(&key) + .query(&mut *conn.inner_mut()) + .map_err(crate::error::AppError::Redis)?; + if count == 1 { + let _ = Cmd::new() + .arg("EXPIRE") + .arg(&key) + .arg(1_u64) + .query::<()>(&mut *conn.inner_mut()); + } + Ok(count <= self.max_per_sec as i64) + } + + pub fn check_sliding(&self, connection_id: Uuid) -> AppResult { + let key = format!("{WS_RATE_PREFIX}{connection_id}"); + let mut conn = self.redis.get_connection()?; + let count: i64 = Cmd::new() + .arg("INCR") + .arg(&key) + .query(&mut *conn.inner_mut()) + .map_err(crate::error::AppError::Redis)?; + if count == 1 { + let _ = Cmd::new() + .arg("EXPIRE") + .arg(&key) + .arg(2_u64) + .query::<()>(&mut *conn.inner_mut()); + } + Ok(count <= self.max_per_sec as i64) + } + + pub fn remaining(&self, connection_id: Uuid) -> AppResult { + let key = format!("{WS_RATE_PREFIX}{connection_id}"); + let mut conn = self.redis.get_connection()?; + let count: Option = Cmd::new() + .arg("GET") + .arg(&key) + .query(&mut *conn.inner_mut()) + .map_err(crate::error::AppError::Redis)?; + Ok(self.max_per_sec.saturating_sub(count.unwrap_or(0) as u32)) + } +} + +pub struct LocalRateLimiter { + count: std::sync::atomic::AtomicU32, + start: std::sync::Mutex, + max_per_sec: u32, +} + +impl LocalRateLimiter { + pub fn new(max_per_sec: u32) -> Self { + Self { + count: std::sync::atomic::AtomicU32::new(0), + start: std::sync::Mutex::new(Instant::now()), + max_per_sec, + } + } + + pub fn check(&self) -> bool { + let mut start = self.start.lock().unwrap(); + if start.elapsed().as_secs() >= 1 { + self.count.store(0, std::sync::atomic::Ordering::Relaxed); + *start = Instant::now(); + } + drop(start); + self.count + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + < self.max_per_sec + } +} diff --git a/immediate/reconnect.rs b/immediate/reconnect.rs new file mode 100644 index 0000000..6a0023f --- /dev/null +++ b/immediate/reconnect.rs @@ -0,0 +1,101 @@ +use std::collections::HashMap; + +use uuid::Uuid; + +use crate::cache::redis::AppRedis; +use crate::error::{AppError, AppResult}; +use ::redis::Cmd; + +use super::redis_keys::*; + +pub struct ReconnectManager { + redis: AppRedis, +} + +impl ReconnectManager { + pub fn new(redis: AppRedis) -> Self { + Self { redis } + } + + pub fn save_read_position(&self, user_id: Uuid, channel_id: Uuid, seq: i64) -> AppResult<()> { + let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}"); + let mut conn = self.redis.get_connection()?; + Cmd::new() + .arg("SETEX") + .arg(&key) + .arg(WS_RECONNECT_STATE_TTL_SECS) + .arg(seq.to_string()) + .query::<()>(&mut *conn.inner_mut()) + .map_err(AppError::Redis)?; + Ok(()) + } + + pub fn save_read_positions( + &self, + user_id: Uuid, + positions: &HashMap, + ) -> AppResult<()> { + let mut conn = self.redis.get_connection()?; + for (channel_id, seq) in positions { + let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}"); + Cmd::new() + .arg("SETEX") + .arg(&key) + .arg(WS_RECONNECT_STATE_TTL_SECS) + .arg(seq.to_string()) + .query::<()>(&mut *conn.inner_mut()) + .map_err(AppError::Redis)?; + } + Ok(()) + } + + pub fn get_last_seq(&self, user_id: Uuid, channel_id: Uuid) -> AppResult> { + let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}"); + let mut conn = self.redis.get_connection()?; + let val: Option = Cmd::new() + .arg("GET") + .arg(&key) + .query(&mut *conn.inner_mut()) + .map_err(AppError::Redis)?; + Ok(val.and_then(|v| v.parse().ok())) + } + + pub fn get_all_positions(&self, user_id: Uuid) -> AppResult> { + let pattern = format!("{WS_RECONNECT_PREFIX}{user_id}:*"); + let mut conn = self.redis.get_connection()?; + let keys: Vec = Cmd::new() + .arg("KEYS") + .arg(&pattern) + .query(&mut *conn.inner_mut()) + .map_err(AppError::Redis)?; + let mut result = HashMap::new(); + let prefix_len = format!("{WS_RECONNECT_PREFIX}{user_id}:").len(); + for key in &keys { + if let Some(channel_str) = key.get(prefix_len..) + && let Ok(channel_id) = channel_str.parse::() + { + let val: Option = Cmd::new() + .arg("GET") + .arg(key) + .query(&mut *conn.inner_mut()) + .map_err(AppError::Redis)?; + if let Some(v) = val + && let Ok(seq) = v.parse::() + { + result.insert(channel_id, seq); + } + } + } + Ok(result) + } + + pub fn cleanup_channel(&self, user_id: Uuid, channel_id: Uuid) -> AppResult<()> { + let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}"); + let mut conn = self.redis.get_connection()?; + let _ = Cmd::new() + .arg("DEL") + .arg(&key) + .query::<()>(&mut *conn.inner_mut()); + Ok(()) + } +} diff --git a/immediate/redis_keys.rs b/immediate/redis_keys.rs new file mode 100644 index 0000000..8371f6e --- /dev/null +++ b/immediate/redis_keys.rs @@ -0,0 +1,19 @@ +#![allow(dead_code)] +pub const WS_TOKEN_PREFIX: &str = "im:ws:token:"; +pub const WS_ONLINE_PREFIX: &str = "im:ws:online:"; +pub const WS_CONNS_PREFIX: &str = "im:ws:conns:"; +pub const WS_SEQ_PREFIX: &str = "im:seq:"; +pub const WS_DEDUP_PREFIX: &str = "im:dedup:"; +pub const WS_RATE_PREFIX: &str = "im:rate:"; +pub const WS_RECONNECT_PREFIX: &str = "im:reconnect:"; + +pub const WS_TOKEN_TTL_SECS: u64 = 30; +pub const WS_ONLINE_TTL_SECS: u64 = 60; +pub const WS_HEARTBEAT_INTERVAL_SECS: u64 = 30; +pub const WS_HEARTBEAT_TIMEOUT_SECS: u64 = 60; +pub const WS_MAX_IDLE_SECS: u64 = 300; +pub const WS_MAX_MESSAGE_BYTES: usize = 64 * 1024; +pub const WS_MAX_MESSAGES_PER_SEC: u32 = 100; +pub const WS_SEQ_SEGMENT_SIZE: u64 = 1024; +pub const WS_DEDUP_WINDOW_SECS: u64 = 300; +pub const WS_RECONNECT_STATE_TTL_SECS: u64 = 86400; diff --git a/immediate/runtime.rs b/immediate/runtime.rs new file mode 100644 index 0000000..6eebc74 --- /dev/null +++ b/immediate/runtime.rs @@ -0,0 +1,52 @@ +use std::sync::Arc; + +use uuid::Uuid; + +use crate::queue::NatsQueue; + +use super::{NatsWsBridge, WsReceiver, WsSender, WsSessionManager, WsSinkManager}; + +#[derive(Clone)] +pub struct WsRuntime { + sessions: Arc, + sinks: Arc, + bridge: NatsWsBridge, +} + +impl WsRuntime { + pub fn new(queue: Arc, sessions: Arc) -> Self { + let sinks = Arc::new(WsSinkManager::new()); + let bridge = NatsWsBridge::new(queue, sessions.clone(), sinks.clone()); + Self { + sessions, + sinks, + bridge, + } + } + + pub fn sinks(&self) -> Arc { + self.sinks.clone() + } + + pub fn sessions(&self) -> Arc { + self.sessions.clone() + } + + pub fn attach(&self, connection_id: Uuid) -> WsReceiver { + let (tx, rx): (WsSender, WsReceiver) = WsSinkManager::channel(); + self.sinks.attach(connection_id, tx); + rx + } + + pub fn detach(&self, connection_id: Uuid) { + self.sinks.detach(connection_id); + self.sessions.unsubscribe_all(connection_id); + } + + pub fn start_nats_bridge(&self) { + let bridge = self.bridge.clone(); + tokio::spawn(async move { + bridge.run_ephemeral("im.>").await; + }); + } +} diff --git a/immediate/seq.rs b/immediate/seq.rs new file mode 100644 index 0000000..8bedb1d --- /dev/null +++ b/immediate/seq.rs @@ -0,0 +1,117 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicI64, Ordering}; + +use dashmap::DashMap; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::cache::redis::AppRedis; +use crate::error::{AppError, AppResult}; +use ::redis::Cmd; + +use super::redis_keys::*; + +struct Segment { + end: i64, + next: AtomicI64, +} + +pub struct SeqAllocator { + redis: AppRedis, + segments: DashMap>, + locks: DashMap>>, + segment_size: u64, +} + +const MAX_RETRIES: u32 = 3; + +impl SeqAllocator { + pub fn new(redis: AppRedis) -> Self { + Self { + redis, + segments: DashMap::new(), + locks: DashMap::new(), + segment_size: WS_SEQ_SEGMENT_SIZE, + } + } + + pub async fn next(&self, channel_id: Uuid) -> AppResult { + for _ in 0..MAX_RETRIES { + if let Some(seq) = self.try_allocate(&channel_id) { + return Ok(seq); + } + let lock = self + .locks + .entry(channel_id) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone(); + let _guard = lock.lock().await; + if let Some(seq) = self.try_allocate(&channel_id) { + return Ok(seq); + } + self.refresh(channel_id).await?; + } + Err(AppError::InternalServerError( + "seq allocation exhausted retries".into(), + )) + } + + pub async fn bootstrap(&self, channel_id: Uuid, db_max: i64) -> AppResult { + let key = format!("{WS_SEQ_PREFIX}{channel_id}"); + let mut conn = self.redis.get_connection()?; + let current: i64 = Cmd::new() + .arg("SET") + .arg(&key) + .arg(db_max) + .arg("NX") + .arg("EX") + .arg(86400) + .query::>(&mut *conn.inner_mut()) + .map_err(AppError::Redis)? + .and_then(|v| v.parse().ok()) + .unwrap_or_else(|| { + let existing: i64 = Cmd::new() + .arg("GET") + .arg(&key) + .query(&mut *conn.inner_mut()) + .map_err(AppError::Redis) + .unwrap_or(db_max); + if existing < db_max { db_max } else { existing } + }); + self.segments.remove(&channel_id); + Ok(current) + } + + fn try_allocate(&self, channel_id: &Uuid) -> Option { + let state = self.segments.get(channel_id)?; + let next = state.next.fetch_add(1, Ordering::Relaxed); + if next < state.end { Some(next) } else { None } + } + + async fn refresh(&self, channel_id: Uuid) -> AppResult<()> { + let key = format!("{WS_SEQ_PREFIX}{channel_id}"); + let mut conn = self.redis.get_connection()?; + let counter: i64 = Cmd::new() + .arg("INCRBY") + .arg(&key) + .arg(self.segment_size as i64) + .query(&mut *conn.inner_mut()) + .map_err(AppError::Redis)?; + + let start = counter - self.segment_size as i64 + 1; + let end = counter + 1; + self.segments.insert( + channel_id, + Arc::new(Segment { + end, + next: AtomicI64::new(start), + }), + ); + let _ = Cmd::new() + .arg("EXPIRE") + .arg(&key) + .arg(86400_u64) + .query::<()>(&mut *conn.inner_mut()); + Ok(()) + } +} diff --git a/immediate/session.rs b/immediate/session.rs new file mode 100644 index 0000000..035143d --- /dev/null +++ b/immediate/session.rs @@ -0,0 +1,301 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Duration; + +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::cache::redis::AppRedis; +use crate::error::{AppError, AppResult}; +use crate::queue::NatsQueue; +use ::redis::Cmd; + +use super::redis_keys::*; +use super::session_redis::{heartbeat_redis, register_redis_online, unregister_redis_online}; +use super::typing; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum WsSessionState { + Connecting, + Authenticated, + Replaced, + Closing, + Closed, +} + +impl WsSessionState { + pub fn is_deliverable(self) -> bool { + matches!(self, Self::Authenticated) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WsSession { + pub user_id: Uuid, + pub device_id: String, + pub connection_id: Uuid, + pub workspace_name: String, + pub connected_at: i64, + pub authenticated_at: Option, + pub state: WsSessionState, + pub superseded_by: Option, +} + +#[derive(Clone)] +pub struct WsSessionManager { + redis: AppRedis, + #[allow(dead_code)] + nats: Arc, + user_devices: Arc>>, + sessions: Arc>, + channel_routes: Arc>>, + session_channels: Arc>>, +} + +impl WsSessionManager { + pub fn new(redis: AppRedis, nats: Arc) -> Self { + Self { + redis, + nats, + user_devices: Arc::new(DashMap::new()), + sessions: Arc::new(DashMap::new()), + channel_routes: Arc::new(DashMap::new()), + session_channels: Arc::new(DashMap::new()), + } + } + + pub fn issue_token(&self, user_id: Uuid, workspace_name: &str) -> AppResult { + self.issue_token_for_device(user_id, workspace_name, "default") + } + + pub fn issue_token_for_device( + &self, + user_id: Uuid, + workspace_name: &str, + device_id: &str, + ) -> AppResult { + let token = format!("ws_{}", Uuid::now_v7()); + let session = WsSession { + user_id, + device_id: device_id.to_string(), + connection_id: Uuid::nil(), + workspace_name: workspace_name.to_string(), + connected_at: 0, + authenticated_at: None, + state: WsSessionState::Connecting, + superseded_by: None, + }; + let json = serde_json::to_string(&session)?; + let key = format!("{WS_TOKEN_PREFIX}{token}"); + let mut conn = self.redis.get_connection()?; + Cmd::new() + .arg("SETEX") + .arg(&key) + .arg(WS_TOKEN_TTL_SECS) + .arg(&json) + .query::<()>(&mut *conn.inner_mut())?; + Ok(token) + } + + pub fn redeem_token(&self, token: &str) -> AppResult { + let key = format!("{WS_TOKEN_PREFIX}{token}"); + let mut conn = self.redis.get_connection()?; + let json: Option = Cmd::new() + .arg("GETDEL") + .arg(&key) + .query::>(&mut *conn.inner_mut()) + .map_err(AppError::Redis)?; + let json = json.ok_or(AppError::Unauthorized)?; + let mut session: WsSession = serde_json::from_str(&json) + .map_err(|e| AppError::Config(format!("invalid ws session: {e}")))?; + let now = chrono::Utc::now().timestamp_millis(); + session.connection_id = Uuid::now_v7(); + session.connected_at = now; + session.authenticated_at = Some(now); + session.state = WsSessionState::Authenticated; + session.superseded_by = None; + Ok(session) + } + + pub fn register_connection(&self, session: &WsSession) -> AppResult<()> { + let _ = self.register_connection_with_replacement(session)?; + Ok(()) + } + + pub fn register_connection_with_replacement( + &self, + session: &WsSession, + ) -> AppResult> { + let mut current = session.clone(); + current.state = WsSessionState::Authenticated; + current.superseded_by = None; + self.sessions.insert(current.connection_id, current.clone()); + + let replaced = { + let mut entry = self.user_devices.entry(current.user_id).or_default(); + entry.insert(current.device_id.clone(), current.connection_id) + }; + + if let Some(old_id) = replaced + && old_id != current.connection_id + { + if let Some(mut old) = self.sessions.get_mut(&old_id) { + old.state = WsSessionState::Replaced; + old.superseded_by = Some(current.connection_id); + } + self.unsubscribe_all(old_id); + } + + register_redis_online(&self.redis, ¤t)?; + Ok(replaced.filter(|old| *old != current.connection_id)) + } + + pub fn unregister_connection(&self, session: &WsSession) -> AppResult<()> { + let removed = self.sessions.remove(&session.connection_id).map(|(_, s)| s); + let current = removed.as_ref().unwrap_or(session); + self.unsubscribe_all(current.connection_id); + + if let Some(mut devices) = self.user_devices.get_mut(¤t.user_id) + && devices.get(¤t.device_id).copied() == Some(current.connection_id) + { + devices.remove(¤t.device_id); + } + self.user_devices + .remove_if(¤t.user_id, |_, devices| devices.is_empty()); + unregister_redis_online(&self.redis, current) + } + + pub fn heartbeat(&self, session: &WsSession) -> AppResult<()> { + if !self.is_deliverable(session.connection_id) { + return Err(AppError::Unauthorized); + } + heartbeat_redis(&self.redis, session) + } + + pub fn subscribe_channel(&self, connection_id: Uuid, channel_id: Uuid) { + self.channel_routes + .entry(channel_id) + .or_default() + .insert(connection_id); + self.session_channels + .entry(connection_id) + .or_default() + .insert(channel_id); + } + + pub fn unsubscribe_channel(&self, connection_id: Uuid, channel_id: Uuid) { + if let Some(mut sessions) = self.channel_routes.get_mut(&channel_id) { + sessions.remove(&connection_id); + } + self.channel_routes + .remove_if(&channel_id, |_, sessions| sessions.is_empty()); + if let Some(mut channels) = self.session_channels.get_mut(&connection_id) { + channels.remove(&channel_id); + } + self.session_channels + .remove_if(&connection_id, |_, channels| channels.is_empty()); + } + + pub fn unsubscribe_all(&self, connection_id: Uuid) { + let channels = self + .session_channels + .remove(&connection_id) + .map(|(_, channels)| channels) + .unwrap_or_default(); + for channel_id in channels { + if let Some(mut sessions) = self.channel_routes.get_mut(&channel_id) { + sessions.remove(&connection_id); + } + self.channel_routes + .remove_if(&channel_id, |_, sessions| sessions.is_empty()); + } + } + + pub fn subscribers(&self, channel_id: Uuid) -> Vec { + self.channel_routes + .get(&channel_id) + .map(|sessions| sessions.iter().copied().collect()) + .unwrap_or_default() + } + + pub fn user_connections(&self, user_id: Uuid) -> Vec { + self.user_devices + .get(&user_id) + .map(|devices| devices.values().copied().collect()) + .unwrap_or_default() + } + + pub fn workspace_connections(&self, workspace_name: &str) -> Vec { + self.sessions + .iter() + .filter_map(|entry| { + let session = entry.value(); + (session.workspace_name == workspace_name && session.state.is_deliverable()) + .then_some(session.connection_id) + }) + .collect() + } + + pub fn get_session(&self, connection_id: Uuid) -> Option { + self.sessions + .get(&connection_id) + .map(|session| session.clone()) + } + + pub fn is_deliverable(&self, connection_id: Uuid) -> bool { + self.sessions + .get(&connection_id) + .map(|session| session.state.is_deliverable() && session.superseded_by.is_none()) + .unwrap_or(false) + } + + pub fn is_user_online(&self, user_id: Uuid) -> AppResult { + Ok(self + .user_devices + .get(&user_id) + .map(|devices| !devices.is_empty()) + .unwrap_or(false)) + } + + pub fn get_connection_count(&self, user_id: Uuid) -> AppResult { + Ok(self + .user_devices + .get(&user_id) + .map(|devices| devices.len() as u32) + .unwrap_or(0)) + } + + pub fn set_typing( + &self, + channel_id: Uuid, + thread_id: Option, + user_id: Uuid, + ) -> AppResult<()> { + typing::set_typing(&self.redis, channel_id, thread_id, user_id) + } + + pub fn clear_typing( + &self, + channel_id: Uuid, + thread_id: Option, + user_id: Uuid, + ) -> AppResult<()> { + typing::clear_typing(&self.redis, channel_id, thread_id, user_id) + } + + pub fn get_typing_users( + &self, + channel_id: Uuid, + thread_id: Option, + ) -> AppResult> { + typing::get_typing_users(&self.redis, channel_id, thread_id) + } + + pub fn heartbeat_interval(&self) -> Duration { + Duration::from_secs(WS_HEARTBEAT_INTERVAL_SECS) + } + pub fn heartbeat_interval_secs(&self) -> u64 { + WS_HEARTBEAT_INTERVAL_SECS + } +} diff --git a/immediate/session_redis.rs b/immediate/session_redis.rs new file mode 100644 index 0000000..695a433 --- /dev/null +++ b/immediate/session_redis.rs @@ -0,0 +1,93 @@ +use crate::cache::redis::AppRedis; +use crate::error::{AppError, AppResult}; +use crate::service::im::util::PRESENCE_PREFIX; +use ::redis::Cmd; + +use super::redis_keys::*; +use super::session::WsSession; + +pub fn register_redis_online(redis: &AppRedis, session: &WsSession) -> AppResult<()> { + let set_key = format!("{WS_ONLINE_PREFIX}{}", session.user_id); + let conn_id = session.connection_id.to_string(); + let meta_key = format!("{WS_CONNS_PREFIX}{}", session.connection_id); + let mut conn = redis.get_connection()?; + + Cmd::new() + .arg("SADD") + .arg(&set_key) + .arg(&conn_id) + .query::(&mut *conn.inner_mut())?; + Cmd::new() + .arg("EXPIRE") + .arg(&set_key) + .arg(WS_ONLINE_TTL_SECS) + .query::<()>(&mut *conn.inner_mut())?; + Cmd::new() + .arg("SETEX") + .arg(&meta_key) + .arg(WS_ONLINE_TTL_SECS) + .arg(session.workspace_name.as_str()) + .query::<()>(&mut *conn.inner_mut())?; + Ok(()) +} + +pub fn unregister_redis_online(redis: &AppRedis, session: &WsSession) -> AppResult<()> { + let set_key = format!("{WS_ONLINE_PREFIX}{}", session.user_id); + let conn_id = session.connection_id.to_string(); + let meta_key = format!("{WS_CONNS_PREFIX}{}", session.connection_id); + let mut conn = redis.get_connection()?; + + Cmd::new() + .arg("SREM") + .arg(&set_key) + .arg(&conn_id) + .query::(&mut *conn.inner_mut())?; + + let remaining: i32 = Cmd::new() + .arg("SCARD") + .arg(&set_key) + .query(&mut *conn.inner_mut()) + .map_err(AppError::Redis)?; + if remaining == 0 { + Cmd::new() + .arg("DEL") + .arg(&set_key) + .query::<()>(&mut *conn.inner_mut())?; + let pk = format!("{PRESENCE_PREFIX}{}", session.user_id); + let _ = Cmd::new() + .arg("DEL") + .arg(&pk) + .query::<()>(&mut *conn.inner_mut()); + } + let _ = Cmd::new() + .arg("DEL") + .arg(&meta_key) + .query::<()>(&mut *conn.inner_mut()); + Ok(()) +} + +pub fn heartbeat_redis(redis: &AppRedis, session: &WsSession) -> AppResult<()> { + let set_key = format!("{WS_ONLINE_PREFIX}{}", session.user_id); + let meta_key = format!("{WS_CONNS_PREFIX}{}", session.connection_id); + let pk = format!("{PRESENCE_PREFIX}{}", session.user_id); + let mut conn = redis.get_connection()?; + + let _ = Cmd::new() + .arg("EXPIRE") + .arg(&set_key) + .arg(WS_ONLINE_TTL_SECS) + .query::<()>(&mut *conn.inner_mut()); + let _ = Cmd::new() + .arg("SETEX") + .arg(&meta_key) + .arg(WS_ONLINE_TTL_SECS) + .arg(session.workspace_name.as_str()) + .query::<()>(&mut *conn.inner_mut()); + let _ = Cmd::new() + .arg("SETEX") + .arg(&pk) + .arg(WS_ONLINE_TTL_SECS) + .arg("online") + .query::<()>(&mut *conn.inner_mut()); + Ok(()) +} diff --git a/immediate/sink.rs b/immediate/sink.rs new file mode 100644 index 0000000..23e9092 --- /dev/null +++ b/immediate/sink.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use dashmap::DashMap; +use tokio::sync::mpsc; +use uuid::Uuid; + +use super::WsOutbound; + +pub type WsSender = mpsc::UnboundedSender; +pub type WsReceiver = mpsc::UnboundedReceiver; + +#[derive(Clone, Default)] +pub struct WsSinkManager { + sinks: Arc>, +} + +impl WsSinkManager { + pub fn new() -> Self { + Self::default() + } + + pub fn channel() -> (WsSender, WsReceiver) { + mpsc::unbounded_channel() + } + + pub fn attach(&self, connection_id: Uuid, sender: WsSender) { + self.sinks.insert(connection_id, sender); + } + + pub fn detach(&self, connection_id: Uuid) { + self.sinks.remove(&connection_id); + } + + pub fn send(&self, connection_id: Uuid, message: WsOutbound) -> bool { + self.sinks + .get(&connection_id) + .map(|sink| sink.send(message).is_ok()) + .unwrap_or(false) + } + + pub fn send_many(&self, ids: I, message: WsOutbound) -> usize + where + I: IntoIterator, + { + ids.into_iter() + .filter(|id| self.send(*id, message.clone())) + .count() + } + + pub fn contains(&self, connection_id: Uuid) -> bool { + self.sinks.contains_key(&connection_id) + } +} diff --git a/immediate/typing.rs b/immediate/typing.rs new file mode 100644 index 0000000..16a72ec --- /dev/null +++ b/immediate/typing.rs @@ -0,0 +1,71 @@ +use uuid::Uuid; + +use crate::cache::redis::AppRedis; +use crate::error::{AppError, AppResult}; +use crate::service::im::util::{TYPING_PREFIX, TYPING_TTL_SECS}; +use ::redis::Cmd; + +pub fn set_typing( + redis: &AppRedis, + channel_id: Uuid, + thread_id: Option, + user_id: Uuid, +) -> AppResult<()> { + let key = typing_key(channel_id, thread_id, user_id); + let mut conn = redis.get_connection()?; + Cmd::new() + .arg("SETEX") + .arg(&key) + .arg(TYPING_TTL_SECS as u64) + .arg("1") + .query::<()>(&mut *conn.inner_mut())?; + Ok(()) +} + +pub fn clear_typing( + redis: &AppRedis, + channel_id: Uuid, + thread_id: Option, + user_id: Uuid, +) -> AppResult<()> { + let key = typing_key(channel_id, thread_id, user_id); + let mut conn = redis.get_connection()?; + Cmd::new() + .arg("DEL") + .arg(&key) + .query::<()>(&mut *conn.inner_mut())?; + Ok(()) +} + +pub fn get_typing_users( + redis: &AppRedis, + channel_id: Uuid, + thread_id: Option, +) -> AppResult> { + let pattern = match thread_id { + Some(tid) => format!("{TYPING_PREFIX}{channel_id}:{tid}:*"), + None => format!("{TYPING_PREFIX}{channel_id}:*"), + }; + let mut conn = redis.get_connection()?; + let keys: Vec = Cmd::new() + .arg("KEYS") + .arg(&pattern) + .query(&mut *conn.inner_mut()) + .map_err(AppError::Redis)?; + let mut ids = Vec::with_capacity(keys.len()); + for key in &keys { + if let Some(part) = key.rsplit(':').next() + && let Ok(uid) = part.parse::() + { + ids.push(uid); + } + } + Ok(ids) +} + +fn typing_key(channel_id: Uuid, thread_id: Option, user_id: Uuid) -> String { + match thread_id { + Some(tid) => format!("{TYPING_PREFIX}{channel_id}:{tid}:{user_id}"), + None => format!("{TYPING_PREFIX}{channel_id}:{user_id}"), + } +} diff --git a/lib.rs b/lib.rs new file mode 100644 index 0000000..ba748ff --- /dev/null +++ b/lib.rs @@ -0,0 +1,12 @@ +pub mod cache; +pub mod config; +pub mod error; +pub mod etcd; +pub mod immediate; +pub mod models; +pub mod pb; +pub mod queue; +pub mod service; +pub mod session; +pub mod storage; +pub mod api; \ No newline at end of file diff --git a/main.rs b/main.rs new file mode 100644 index 0000000..e7a11a9 --- /dev/null +++ b/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} diff --git a/migrate/001_init.sql b/migrate/001_init.sql new file mode 100644 index 0000000..b8bacd4 --- /dev/null +++ b/migrate/001_init.sql @@ -0,0 +1,2107 @@ +-- ============================================================ +-- Migration: 001_init.sql +-- Tables: 106 | ALL FKs → ON DELETE CASCADE +-- storage_node_id → NO FK (business-layer ref to etcd) +-- Circular/self-referencing FKs deferred to ALTER TABLE +-- ============================================================ + +BEGIN; + +-- PHASE A: Create tables in dependency order + +-- models/agents/agent_execution_steps.rs → agent_execution_step +CREATE TABLE IF NOT EXISTS agent_execution_step ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + execution_id UUID NOT NULL, + step_index INTEGER NOT NULL, + step_type TEXT NOT NULL, + name TEXT NOT NULL, + status TEXT NOT NULL, + model_id UUID NULL, + tool_name TEXT NULL, + input JSONB NULL, + output JSONB NULL, + error_message TEXT NULL, + token_input_count INTEGER NULL, + token_output_count INTEGER NULL, + started_at TIMESTAMPTZ NULL, + finished_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_agent_execution_step_execution_id ON agent_execution_step (execution_id); +CREATE INDEX IF NOT EXISTS idx_agent_execution_step_model_id ON agent_execution_step (model_id); +CREATE INDEX IF NOT EXISTS idx_agent_execution_step_status_created ON agent_execution_step (status, created_at DESC); + +-- models/ais/ai_models.rs → ai_model +CREATE TABLE IF NOT EXISTS ai_model ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider TEXT NOT NULL, + model_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NULL, + model_type TEXT NOT NULL, + family TEXT NULL, + version TEXT NULL, + context_window INTEGER NULL, + max_output_tokens INTEGER NULL, + input_modalities TEXT[] NOT NULL, + output_modalities TEXT[] NOT NULL, + supported_features TEXT[] NOT NULL, + pricing_unit TEXT NULL, + input_price_per_unit TEXT NULL, + output_price_per_unit TEXT NULL, + enabled BOOLEAN NOT NULL, + deprecated BOOLEAN NOT NULL, + released_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ NULL + +); +CREATE INDEX IF NOT EXISTS idx_ai_model_model_id ON ai_model (model_id); +CREATE INDEX IF NOT EXISTS idx_ai_model_deleted ON ai_model (deleted_at) WHERE deleted_at IS NOT NULL; + +-- models/users/user.rs → user +CREATE TABLE IF NOT EXISTS "user" ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT NOT NULL, + display_name TEXT NULL, + avatar_url TEXT NULL, + bio TEXT NULL, + status TEXT NOT NULL, + role TEXT NOT NULL, + visibility TEXT NOT NULL, + is_active BOOLEAN NOT NULL, + is_bot BOOLEAN NOT NULL, + last_login_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ NULL + +); +CREATE INDEX IF NOT EXISTS idx_user_status_created ON "user" (status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_user_deleted ON "user" (deleted_at) WHERE deleted_at IS NOT NULL; + +-- models/ais/ai_model_capabilities.rs → ai_model_capability +CREATE TABLE IF NOT EXISTS ai_model_capability ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ai_model_id UUID NOT NULL REFERENCES ai_model(id) ON DELETE CASCADE, + capability TEXT NOT NULL, + supported BOOLEAN NOT NULL, + config JSONB NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_ai_model_capability_ai_model_id ON ai_model_capability (ai_model_id); + +-- models/ais/ai_model_health.rs → ai_model_health +CREATE TABLE IF NOT EXISTS ai_model_health ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ai_model_id UUID NOT NULL REFERENCES ai_model(id) ON DELETE CASCADE, + status TEXT NOT NULL, + latency_ms INTEGER NULL, + error_rate TEXT NULL, + last_error TEXT NULL, + checked_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_ai_model_health_ai_model_id ON ai_model_health (ai_model_id); +CREATE INDEX IF NOT EXISTS idx_ai_model_health_status_created ON ai_model_health (status, created_at DESC); + +-- models/ais/ai_model_versions.rs → ai_model_version +CREATE TABLE IF NOT EXISTS ai_model_version ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ai_model_id UUID NOT NULL REFERENCES ai_model(id) ON DELETE CASCADE, + version TEXT NOT NULL, + provider_model_id TEXT NOT NULL, + context_window INTEGER NULL, + max_output_tokens INTEGER NULL, + changelog TEXT NULL, + stable BOOLEAN NOT NULL, + deprecated BOOLEAN NOT NULL, + released_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_ai_model_version_ai_model_id ON ai_model_version (ai_model_id); +CREATE INDEX IF NOT EXISTS idx_ai_model_version_provider_model_id ON ai_model_version (provider_model_id); + +-- models/notifications/notification_deliveries.rs → notification_delivery +CREATE TABLE IF NOT EXISTS notification_delivery ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + notification_id UUID NOT NULL, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + channel TEXT NOT NULL, + destination TEXT NULL, + status TEXT NOT NULL, + provider TEXT NULL, + provider_message_id TEXT NULL, + attempts INTEGER NOT NULL, + last_error TEXT NULL, + scheduled_at TIMESTAMPTZ NULL, + sent_at TIMESTAMPTZ NULL, + delivered_at TIMESTAMPTZ NULL, + failed_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_notification_delivery_notification_id ON notification_delivery (notification_id); +CREATE INDEX IF NOT EXISTS idx_notification_delivery_user_id ON notification_delivery (user_id); +CREATE INDEX IF NOT EXISTS idx_notification_delivery_provider_message_id ON notification_delivery (provider_message_id); +CREATE INDEX IF NOT EXISTS idx_notification_delivery_user_created ON notification_delivery (user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notification_delivery_status_created ON notification_delivery (status, created_at DESC); + +-- models/notifications/notification_templates.rs → notification_template +CREATE TABLE IF NOT EXISTS notification_template ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key TEXT NOT NULL, + notification_type TEXT NOT NULL, + channel TEXT NOT NULL, + locale TEXT NOT NULL, + subject_template TEXT NULL, + title_template TEXT NOT NULL, + body_template TEXT NOT NULL, + action_text_template TEXT NULL, + enabled BOOLEAN NOT NULL, + created_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); + +-- models/users/user_appearance.rs → user_appearance +CREATE TABLE IF NOT EXISTS user_appearance ( + PRIMARY KEY (user_id), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + theme TEXT NOT NULL, + color_scheme TEXT NOT NULL, + density TEXT NOT NULL, + font_size TEXT NOT NULL, + editor_theme TEXT NULL, + markdown_preview BOOLEAN NOT NULL, + reduced_motion BOOLEAN NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_user_appearance_user_created ON user_appearance (user_id, created_at DESC); + +-- models/users/user_block.rs → user_block +CREATE TABLE IF NOT EXISTS user_block ( + PRIMARY KEY (blocker_id, blocked_id), + blocker_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + blocked_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + reason TEXT NULL, + created_at TIMESTAMPTZ NOT NULL + +); + +-- models/users/user_device.rs → user_device +CREATE TABLE IF NOT EXISTS user_device ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + device_name TEXT NOT NULL, + device_type TEXT NOT NULL, + fingerprint TEXT NULL, + ip_address TEXT NULL, + user_agent TEXT NULL, + trusted BOOLEAN NOT NULL, + last_seen_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_user_device_user_id ON user_device (user_id); +CREATE INDEX IF NOT EXISTS idx_user_device_user_created ON user_device (user_id, created_at DESC); + +-- models/users/user_follow.rs → user_follow +CREATE TABLE IF NOT EXISTS user_follow ( + PRIMARY KEY (follower_id, following_id), + follower_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + following_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL + +); + +-- models/users/user_gpg_key.rs → user_gpg_key +CREATE TABLE IF NOT EXISTS user_gpg_key ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + key_id TEXT NOT NULL, + public_key TEXT NOT NULL, + fingerprint TEXT NOT NULL, + primary_email TEXT NULL, + expires_at TIMESTAMPTZ NULL, + verified_at TIMESTAMPTZ NULL, + revoked_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_user_gpg_key_user_id ON user_gpg_key (user_id); +CREATE INDEX IF NOT EXISTS idx_user_gpg_key_key_id ON user_gpg_key (key_id); +CREATE INDEX IF NOT EXISTS idx_user_gpg_key_user_created ON user_gpg_key (user_id, created_at DESC); + +-- models/users/user_mail.rs → user_mail +CREATE TABLE IF NOT EXISTS user_mail ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + email TEXT NOT NULL, + is_primary BOOLEAN NOT NULL, + is_verified BOOLEAN NOT NULL, + verification_token_hash TEXT NULL, + verified_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_user_mail_user_id ON user_mail (user_id); +CREATE INDEX IF NOT EXISTS idx_user_mail_user_created ON user_mail (user_id, created_at DESC); + +-- models/users/user_notify_setting.rs → user_notify_setting +CREATE TABLE IF NOT EXISTS user_notify_setting ( + PRIMARY KEY (user_id), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + email_notifications BOOLEAN NOT NULL, + web_notifications BOOLEAN NOT NULL, + mention_notifications BOOLEAN NOT NULL, + review_notifications BOOLEAN NOT NULL, + security_notifications BOOLEAN NOT NULL, + marketing_emails BOOLEAN NOT NULL, + digest_frequency TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_user_notify_setting_user_created ON user_notify_setting (user_id, created_at DESC); + +-- models/users/user_oauth.rs → user_oauth +CREATE TABLE IF NOT EXISTS user_oauth ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + provider_username TEXT NULL, + provider_email TEXT NULL, + access_token_ciphertext TEXT NULL, + refresh_token_ciphertext TEXT NULL, + token_expires_at TIMESTAMPTZ NULL, + linked_at TIMESTAMPTZ NOT NULL, + last_used_at TIMESTAMPTZ NULL + +); +CREATE INDEX IF NOT EXISTS idx_user_oauth_user_id ON user_oauth (user_id); +CREATE INDEX IF NOT EXISTS idx_user_oauth_provider_user_id ON user_oauth (provider_user_id); + +-- models/users/user_password.rs → user_password +CREATE TABLE IF NOT EXISTS user_password ( + PRIMARY KEY (user_id), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + password_hash TEXT NOT NULL, + password_algo TEXT NOT NULL, + password_salt TEXT NULL, + must_change_password BOOLEAN NOT NULL, + password_updated_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_user_password_user_created ON user_password (user_id, created_at DESC); + +-- models/users/user_password_reset.rs → user_password_reset +CREATE TABLE IF NOT EXISTS user_password_reset ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + requested_ip TEXT NULL, + user_agent TEXT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_user_password_reset_user_id ON user_password_reset (user_id); +CREATE INDEX IF NOT EXISTS idx_user_password_reset_user_created ON user_password_reset (user_id, created_at DESC); + +-- models/users/user_personal_access_token.rs → user_personal_access_token +CREATE TABLE IF NOT EXISTS user_personal_access_token ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + name TEXT NOT NULL, + token_hash TEXT NOT NULL, + scopes TEXT[] NOT NULL, + last_used_at TIMESTAMPTZ NULL, + expires_at TIMESTAMPTZ NULL, + revoked_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_user_personal_access_token_user_id ON user_personal_access_token (user_id); +CREATE INDEX IF NOT EXISTS idx_user_personal_access_token_user_created ON user_personal_access_token (user_id, created_at DESC); + +-- models/users/user_profile.rs → user_profile +CREATE TABLE IF NOT EXISTS user_profile ( + PRIMARY KEY (user_id), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + full_name TEXT NULL, + company TEXT NULL, + location TEXT NULL, + website_url TEXT NULL, + twitter_username TEXT NULL, + timezone TEXT NULL, + language TEXT NULL, + profile_readme TEXT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_user_profile_user_created ON user_profile (user_id, created_at DESC); + +-- models/users/user_security_log.rs → user_security_log +CREATE TABLE IF NOT EXISTS user_security_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + description TEXT NULL, + ip_address TEXT NULL, + user_agent TEXT NULL, + metadata JSONB NULL, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_user_security_log_user_id ON user_security_log (user_id); +CREATE INDEX IF NOT EXISTS idx_user_security_log_user_created ON user_security_log (user_id, created_at DESC); + +-- models/users/user_session.rs → user_session +CREATE TABLE IF NOT EXISTS user_session ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + session_token_hash TEXT NOT NULL, + refresh_token_hash TEXT NULL, + ip_address TEXT NULL, + user_agent TEXT NULL, + last_active_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_user_session_user_id ON user_session (user_id); +CREATE INDEX IF NOT EXISTS idx_user_session_user_created ON user_session (user_id, created_at DESC); + +-- models/users/user_ssh_key.rs → user_ssh_key +CREATE TABLE IF NOT EXISTS user_ssh_key ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + title TEXT NOT NULL, + public_key TEXT NOT NULL, + fingerprint_sha256 TEXT NOT NULL, + key_type TEXT NOT NULL, + last_used_at TIMESTAMPTZ NULL, + expires_at TIMESTAMPTZ NULL, + revoked_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_user_ssh_key_user_id ON user_ssh_key (user_id); +CREATE INDEX IF NOT EXISTS idx_user_ssh_key_user_created ON user_ssh_key (user_id, created_at DESC); + +-- models/workspaces/workspace.rs → workspace +CREATE TABLE IF NOT EXISTS workspace ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + slug TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NULL, + avatar_url TEXT NULL, + visibility TEXT NOT NULL, + plan TEXT NOT NULL, + status TEXT NOT NULL, + default_role TEXT NOT NULL, + is_personal BOOLEAN NOT NULL, + archived_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ NULL + +); +CREATE INDEX IF NOT EXISTS idx_workspace_owner_id ON workspace (owner_id); +CREATE INDEX IF NOT EXISTS idx_workspace_status_created ON workspace (status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_workspace_deleted ON workspace (deleted_at) WHERE deleted_at IS NOT NULL; + +-- models/agents/agent.rs → agent +CREATE TABLE IF NOT EXISTS agent ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + workspace_id UUID NULL REFERENCES workspace(id) ON DELETE CASCADE, + name TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT NULL, + avatar_url TEXT NULL, + agent_type TEXT NOT NULL, + status TEXT NOT NULL, + visibility TEXT NOT NULL, + default_model_id UUID NULL REFERENCES ai_model(id) ON DELETE CASCADE, + current_version_id UUID NULL, + system_prompt TEXT NULL, + tools TEXT[] NOT NULL, + tags TEXT[] NOT NULL, + enabled BOOLEAN NOT NULL, + last_run_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ NULL + +); +CREATE INDEX IF NOT EXISTS idx_agent_owner_id ON agent (owner_id); +CREATE INDEX IF NOT EXISTS idx_agent_workspace_id ON agent (workspace_id); +CREATE INDEX IF NOT EXISTS idx_agent_default_model_id ON agent (default_model_id); +CREATE INDEX IF NOT EXISTS idx_agent_current_version_id ON agent (current_version_id); +CREATE INDEX IF NOT EXISTS idx_agent_ws_created ON agent (workspace_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_agent_status_created ON agent (status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_agent_deleted ON agent (deleted_at) WHERE deleted_at IS NOT NULL; + +-- models/repos/repo.rs → repo +CREATE TABLE IF NOT EXISTS repo ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + owner_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + slug TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NULL, + default_branch TEXT NOT NULL, + visibility TEXT NOT NULL, + status TEXT NOT NULL, + is_fork BOOLEAN NOT NULL, + forked_from_repo_id UUID NULL REFERENCES repo(id) ON DELETE CASCADE, + storage_node_ids UUID[] NOT NULL, + primary_storage_node_id UUID NOT NULL, + storage_path TEXT NOT NULL, + git_service TEXT NOT NULL, + archived_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ NULL + +); +CREATE INDEX IF NOT EXISTS idx_repo_workspace_id ON repo (workspace_id); +CREATE INDEX IF NOT EXISTS idx_repo_owner_id ON repo (owner_id); +CREATE INDEX IF NOT EXISTS idx_repo_forked_from_repo_id ON repo (forked_from_repo_id); +CREATE INDEX IF NOT EXISTS idx_repo_primary_storage_node_id ON repo (primary_storage_node_id); +CREATE INDEX IF NOT EXISTS idx_repo_ws_created ON repo (workspace_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_repo_status_created ON repo (status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_repo_deleted ON repo (deleted_at) WHERE deleted_at IS NOT NULL; + +-- models/workspaces/workspace_audit_logs.rs → workspace_audit_log +CREATE TABLE IF NOT EXISTS workspace_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + actor_id UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + action TEXT NOT NULL, + target_type TEXT NULL, + target_id UUID NULL, + ip_address TEXT NULL, + user_agent TEXT NULL, + metadata JSONB NULL, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_workspace_audit_log_workspace_id ON workspace_audit_log (workspace_id); +CREATE INDEX IF NOT EXISTS idx_workspace_audit_log_actor_id ON workspace_audit_log (actor_id); +CREATE INDEX IF NOT EXISTS idx_workspace_audit_log_target_id ON workspace_audit_log (target_id); +CREATE INDEX IF NOT EXISTS idx_workspace_audit_log_ws_created ON workspace_audit_log (workspace_id, created_at DESC); + +-- models/workspaces/workspace_billing.rs → workspace_billing +CREATE TABLE IF NOT EXISTS workspace_billing ( + PRIMARY KEY (workspace_id), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + customer_id TEXT NULL, + subscription_id TEXT NULL, + plan TEXT NOT NULL, + billing_email TEXT NULL, + status TEXT NOT NULL, + seats INTEGER NOT NULL, + trial_ends_at TIMESTAMPTZ NULL, + current_period_start TIMESTAMPTZ NULL, + current_period_end TIMESTAMPTZ NULL, + canceled_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_workspace_billing_customer_id ON workspace_billing (customer_id); +CREATE INDEX IF NOT EXISTS idx_workspace_billing_subscription_id ON workspace_billing (subscription_id); +CREATE INDEX IF NOT EXISTS idx_workspace_billing_ws_created ON workspace_billing (workspace_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_workspace_billing_status_created ON workspace_billing (status, created_at DESC); + +-- models/workspaces/workspace_custom_branding.rs → workspace_custom_branding +CREATE TABLE IF NOT EXISTS workspace_custom_branding ( + PRIMARY KEY (workspace_id), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + logo_url TEXT NULL, + favicon_url TEXT NULL, + primary_color TEXT NULL, + accent_color TEXT NULL, + custom_css TEXT NULL, + support_url TEXT NULL, + enabled BOOLEAN NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_workspace_custom_branding_ws_created ON workspace_custom_branding (workspace_id, created_at DESC); + +-- models/workspaces/workspace_domains.rs → workspace_domain +CREATE TABLE IF NOT EXISTS workspace_domain ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + domain TEXT NOT NULL, + verification_token_hash TEXT NULL, + is_primary BOOLEAN NOT NULL, + is_verified BOOLEAN NOT NULL, + verified_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +, + CONSTRAINT uq_workspace_domain UNIQUE (workspace_id, domain) + +); +CREATE INDEX IF NOT EXISTS idx_workspace_domain_workspace_id ON workspace_domain (workspace_id); +CREATE INDEX IF NOT EXISTS idx_workspace_domain_ws_created ON workspace_domain (workspace_id, created_at DESC); + +-- models/workspaces/workspace_integrations.rs → workspace_integration +CREATE TABLE IF NOT EXISTS workspace_integration ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + name TEXT NOT NULL, + config JSONB NULL, + secret_ciphertext TEXT NULL, + enabled BOOLEAN NOT NULL, + installed_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + last_used_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_workspace_integration_workspace_id ON workspace_integration (workspace_id); +CREATE INDEX IF NOT EXISTS idx_workspace_integration_ws_created ON workspace_integration (workspace_id, created_at DESC); + +-- models/workspaces/workspace_invitations.rs → workspace_invitation +CREATE TABLE IF NOT EXISTS workspace_invitation ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + email TEXT NOT NULL, + role TEXT NOT NULL, + token_hash TEXT NOT NULL, + invited_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + accepted_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + accepted_at TIMESTAMPTZ NULL, + revoked_at TIMESTAMPTZ NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_workspace_invitation_workspace_id ON workspace_invitation (workspace_id); +CREATE INDEX IF NOT EXISTS idx_workspace_invitation_ws_created ON workspace_invitation (workspace_id, created_at DESC); + +-- models/workspaces/workspace_members.rs → workspace_member +CREATE TABLE IF NOT EXISTS workspace_member ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + role TEXT NOT NULL, + status TEXT NOT NULL, + invited_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + joined_at TIMESTAMPTZ NULL, + last_active_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +, + CONSTRAINT uq_workspace_member UNIQUE (workspace_id, user_id) + +); +CREATE INDEX IF NOT EXISTS idx_workspace_member_workspace_id ON workspace_member (workspace_id); +CREATE INDEX IF NOT EXISTS idx_workspace_member_user_id ON workspace_member (user_id); +CREATE INDEX IF NOT EXISTS idx_workspace_member_ws_created ON workspace_member (workspace_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_workspace_member_user_created ON workspace_member (user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_workspace_member_status_created ON workspace_member (status, created_at DESC); + +-- models/workspaces/workspace_pending_approvals.rs → workspace_pending_approval +CREATE TABLE IF NOT EXISTS workspace_pending_approval ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + requester_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + request_type TEXT NOT NULL, + status TEXT NOT NULL, + reason TEXT NULL, + reviewed_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + reviewed_at TIMESTAMPTZ NULL, + expires_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_workspace_pending_approval_workspace_id ON workspace_pending_approval (workspace_id); +CREATE INDEX IF NOT EXISTS idx_workspace_pending_approval_requester_id ON workspace_pending_approval (requester_id); +CREATE INDEX IF NOT EXISTS idx_workspace_pending_approval_ws_created ON workspace_pending_approval (workspace_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_workspace_pending_approval_status_created ON workspace_pending_approval (status, created_at DESC); + +-- models/workspaces/workspace_settings.rs → workspace_settings +CREATE TABLE IF NOT EXISTS workspace_settings ( + PRIMARY KEY (workspace_id), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + allow_public_repos BOOLEAN NOT NULL, + allow_member_invites BOOLEAN NOT NULL, + require_two_factor BOOLEAN NOT NULL, + default_repo_visibility TEXT NOT NULL, + default_branch_name TEXT NOT NULL, + issue_tracking_enabled BOOLEAN NOT NULL, + pull_requests_enabled BOOLEAN NOT NULL, + wiki_enabled BOOLEAN NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_workspace_settings_ws_created ON workspace_settings (workspace_id, created_at DESC); + +-- models/workspaces/workspace_stats.rs → workspace_stats +CREATE TABLE IF NOT EXISTS workspace_stats ( + PRIMARY KEY (workspace_id), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + members_count BIGINT NOT NULL, + repos_count BIGINT NOT NULL, + issues_count BIGINT NOT NULL, + pull_requests_count BIGINT NOT NULL, + storage_bytes BIGINT NOT NULL, + bandwidth_bytes BIGINT NOT NULL, + build_minutes_used BIGINT NOT NULL, + last_activity_at TIMESTAMPTZ NULL, + updated_at TIMESTAMPTZ NOT NULL + +); + +-- models/workspaces/workspace_webhooks.rs → workspace_webhook +CREATE TABLE IF NOT EXISTS workspace_webhook ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + url TEXT NOT NULL, + secret_ciphertext TEXT NULL, + events TEXT[] NOT NULL, + active BOOLEAN NOT NULL, + last_delivery_status TEXT NULL, + last_delivery_at TIMESTAMPTZ NULL, + created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_workspace_webhook_workspace_id ON workspace_webhook (workspace_id); +CREATE INDEX IF NOT EXISTS idx_workspace_webhook_ws_created ON workspace_webhook (workspace_id, created_at DESC); + +-- models/agents/agent_versions.rs → agent_version +CREATE TABLE IF NOT EXISTS agent_version ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id UUID NOT NULL REFERENCES agent(id) ON DELETE CASCADE, + version TEXT NOT NULL, + name TEXT NULL, + description TEXT NULL, + model_id UUID NULL, + system_prompt TEXT NOT NULL, + instructions TEXT NULL, + tools TEXT[] NOT NULL, + config JSONB NULL, + changelog TEXT NULL, + stable BOOLEAN NOT NULL, + published_by UUID NOT NULL, + published_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_agent_version_agent_id ON agent_version (agent_id); +CREATE INDEX IF NOT EXISTS idx_agent_version_model_id ON agent_version (model_id); + +-- models/agents/agent_event_subscriptions.rs → agent_event_subscription +CREATE TABLE IF NOT EXISTS agent_event_subscription ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id UUID NOT NULL REFERENCES agent(id) ON DELETE CASCADE, + workspace_id UUID NULL REFERENCES workspace(id) ON DELETE CASCADE, + repo_id UUID NULL REFERENCES repo(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + filters JSONB NULL, + enabled BOOLEAN NOT NULL, + created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_agent_event_subscription_agent_id ON agent_event_subscription (agent_id); +CREATE INDEX IF NOT EXISTS idx_agent_event_subscription_workspace_id ON agent_event_subscription (workspace_id); +CREATE INDEX IF NOT EXISTS idx_agent_event_subscription_repo_id ON agent_event_subscription (repo_id); +CREATE INDEX IF NOT EXISTS idx_agent_event_subscription_repo_created ON agent_event_subscription (repo_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_agent_event_subscription_ws_created ON agent_event_subscription (workspace_id, created_at DESC); + +-- models/agents/agent_schedules.rs → agent_schedule +CREATE TABLE IF NOT EXISTS agent_schedule ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id UUID NOT NULL REFERENCES agent(id) ON DELETE CASCADE, + workspace_id UUID NULL REFERENCES workspace(id) ON DELETE CASCADE, + repo_id UUID NULL REFERENCES repo(id) ON DELETE CASCADE, + name TEXT NOT NULL, + cron_expr TEXT NOT NULL, + timezone TEXT NOT NULL, + payload JSONB NULL, + enabled BOOLEAN NOT NULL, + last_run_at TIMESTAMPTZ NULL, + next_run_at TIMESTAMPTZ NULL, + created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_agent_schedule_agent_id ON agent_schedule (agent_id); +CREATE INDEX IF NOT EXISTS idx_agent_schedule_workspace_id ON agent_schedule (workspace_id); +CREATE INDEX IF NOT EXISTS idx_agent_schedule_repo_id ON agent_schedule (repo_id); +CREATE INDEX IF NOT EXISTS idx_agent_schedule_repo_created ON agent_schedule (repo_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_agent_schedule_ws_created ON agent_schedule (workspace_id, created_at DESC); + +-- models/agents/agent_workspace_bindings.rs → agent_workspace_binding +CREATE TABLE IF NOT EXISTS agent_workspace_binding ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id UUID NOT NULL REFERENCES agent(id) ON DELETE CASCADE, + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + repo_id UUID NULL REFERENCES repo(id) ON DELETE CASCADE, + role TEXT NOT NULL, + permissions TEXT[] NOT NULL, + enabled BOOLEAN NOT NULL, + bound_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +, + CONSTRAINT uq_agent_workspace_binding UNIQUE (agent_id, workspace_id) + +); +CREATE INDEX IF NOT EXISTS idx_agent_workspace_binding_agent_id ON agent_workspace_binding (agent_id); +CREATE INDEX IF NOT EXISTS idx_agent_workspace_binding_workspace_id ON agent_workspace_binding (workspace_id); +CREATE INDEX IF NOT EXISTS idx_agent_workspace_binding_repo_id ON agent_workspace_binding (repo_id); +CREATE INDEX IF NOT EXISTS idx_agent_workspace_binding_repo_created ON agent_workspace_binding (repo_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_agent_workspace_binding_ws_created ON agent_workspace_binding (workspace_id, created_at DESC); + +-- models/channels/channel.rs → channel +CREATE TABLE IF NOT EXISTS channel ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + repo_id UUID NULL REFERENCES repo(id) ON DELETE CASCADE, + created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + name TEXT NOT NULL, + topic TEXT NULL, + description TEXT NULL, + channel_type TEXT NOT NULL, + visibility TEXT NOT NULL, + archived BOOLEAN NOT NULL, + read_only BOOLEAN NOT NULL, + last_message_id UUID NULL, + last_message_at TIMESTAMPTZ NULL, + archived_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ NULL + +); +CREATE INDEX IF NOT EXISTS idx_channel_workspace_id ON channel (workspace_id); +CREATE INDEX IF NOT EXISTS idx_channel_repo_id ON channel (repo_id); +CREATE INDEX IF NOT EXISTS idx_channel_last_message_id ON channel (last_message_id); +CREATE INDEX IF NOT EXISTS idx_channel_repo_created ON channel (repo_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_channel_ws_created ON channel (workspace_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_channel_deleted ON channel (deleted_at) WHERE deleted_at IS NOT NULL; + +-- models/issues/issue.rs → issue +CREATE TABLE IF NOT EXISTS issue ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + number BIGINT NOT NULL, + title TEXT NOT NULL, + body TEXT NULL, + state TEXT NOT NULL, + priority TEXT NOT NULL, + visibility TEXT NOT NULL, + locked BOOLEAN NOT NULL, + closed_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + closed_at TIMESTAMPTZ NULL, + due_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ NULL + +); +CREATE INDEX IF NOT EXISTS idx_issue_repo_id ON issue (repo_id); +CREATE INDEX IF NOT EXISTS idx_issue_author_id ON issue (author_id); +CREATE INDEX IF NOT EXISTS idx_issue_repo_created ON issue (repo_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_issue_deleted ON issue (deleted_at) WHERE deleted_at IS NOT NULL; + +-- models/issues/issue_labels.rs → issue_label +CREATE TABLE IF NOT EXISTS issue_label ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT NOT NULL, + description TEXT NULL, + created_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_issue_label_repo_id ON issue_label (repo_id); +CREATE INDEX IF NOT EXISTS idx_issue_label_repo_created ON issue_label (repo_id, created_at DESC); + +-- models/issues/issue_milestones.rs → issue_milestone +CREATE TABLE IF NOT EXISTS issue_milestone ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT NULL, + state TEXT NOT NULL, + due_at TIMESTAMPTZ NULL, + closed_at TIMESTAMPTZ NULL, + created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_issue_milestone_repo_id ON issue_milestone (repo_id); +CREATE INDEX IF NOT EXISTS idx_issue_milestone_repo_created ON issue_milestone (repo_id, created_at DESC); + +-- models/issues/issue_templates.rs → issue_template +CREATE TABLE IF NOT EXISTS issue_template ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NULL, + title_template TEXT NULL, + body_template TEXT NOT NULL, + labels TEXT[] NOT NULL, + active BOOLEAN NOT NULL, + created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_issue_template_repo_id ON issue_template (repo_id); +CREATE INDEX IF NOT EXISTS idx_issue_template_repo_created ON issue_template (repo_id, created_at DESC); + +-- models/notifications/notification_blocks.rs → notification_block +CREATE TABLE IF NOT EXISTS notification_block ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + workspace_id UUID NULL REFERENCES workspace(id) ON DELETE CASCADE, + repo_id UUID NULL REFERENCES repo(id) ON DELETE CASCADE, + target_type TEXT NOT NULL, + target_id UUID NULL, + notification_type TEXT NULL, + channel TEXT NULL, + reason TEXT NULL, + expires_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_notification_block_user_id ON notification_block (user_id); +CREATE INDEX IF NOT EXISTS idx_notification_block_workspace_id ON notification_block (workspace_id); +CREATE INDEX IF NOT EXISTS idx_notification_block_repo_id ON notification_block (repo_id); +CREATE INDEX IF NOT EXISTS idx_notification_block_target_id ON notification_block (target_id); +CREATE INDEX IF NOT EXISTS idx_notification_block_repo_created ON notification_block (repo_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notification_block_ws_created ON notification_block (workspace_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notification_block_user_created ON notification_block (user_id, created_at DESC); + +-- models/notifications/notification_subscriptions.rs → notification_subscription +CREATE TABLE IF NOT EXISTS notification_subscription ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + workspace_id UUID NULL REFERENCES workspace(id) ON DELETE CASCADE, + repo_id UUID NULL REFERENCES repo(id) ON DELETE CASCADE, + target_type TEXT NOT NULL, + target_id UUID NULL, + event_types TEXT[] NOT NULL, + channels TEXT[] NOT NULL, + level TEXT NOT NULL, + muted BOOLEAN NOT NULL, + muted_until TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_notification_subscription_user_id ON notification_subscription (user_id); +CREATE INDEX IF NOT EXISTS idx_notification_subscription_workspace_id ON notification_subscription (workspace_id); +CREATE INDEX IF NOT EXISTS idx_notification_subscription_repo_id ON notification_subscription (repo_id); +CREATE INDEX IF NOT EXISTS idx_notification_subscription_target_id ON notification_subscription (target_id); +CREATE INDEX IF NOT EXISTS idx_notification_subscription_repo_created ON notification_subscription (repo_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notification_subscription_ws_created ON notification_subscription (workspace_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notification_subscription_user_created ON notification_subscription (user_id, created_at DESC); + +-- models/prs/pr_labels.rs → pr_label +CREATE TABLE IF NOT EXISTS pr_label ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT NOT NULL, + description TEXT NULL, + created_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_pr_label_repo_id ON pr_label (repo_id); +CREATE INDEX IF NOT EXISTS idx_pr_label_repo_created ON pr_label (repo_id, created_at DESC); + +-- models/prs/pull_request.rs → pull_request +CREATE TABLE IF NOT EXISTS pull_request ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + number BIGINT NOT NULL, + title TEXT NOT NULL, + body TEXT NULL, + state TEXT NOT NULL, + source_repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + source_branch TEXT NOT NULL, + target_repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + target_branch TEXT NOT NULL, + base_commit_sha TEXT NULL, + head_commit_sha TEXT NOT NULL, + merge_commit_sha TEXT NULL, + draft BOOLEAN NOT NULL, + locked BOOLEAN NOT NULL, + merged_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + merged_at TIMESTAMPTZ NULL, + closed_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + closed_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ NULL + +); +CREATE INDEX IF NOT EXISTS idx_pull_request_repo_id ON pull_request (repo_id); +CREATE INDEX IF NOT EXISTS idx_pull_request_author_id ON pull_request (author_id); +CREATE INDEX IF NOT EXISTS idx_pull_request_source_repo_id ON pull_request (source_repo_id); +CREATE INDEX IF NOT EXISTS idx_pull_request_target_repo_id ON pull_request (target_repo_id); +CREATE INDEX IF NOT EXISTS idx_pull_request_repo_created ON pull_request (repo_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_pull_request_deleted ON pull_request (deleted_at) WHERE deleted_at IS NOT NULL; + +-- models/repos/repo_deploy_keys.rs → repo_deploy_key +CREATE TABLE IF NOT EXISTS repo_deploy_key ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + title TEXT NOT NULL, + public_key TEXT NOT NULL, + fingerprint_sha256 TEXT NOT NULL, + key_type TEXT NOT NULL, + read_only BOOLEAN NOT NULL, + last_used_at TIMESTAMPTZ NULL, + expires_at TIMESTAMPTZ NULL, + revoked_at TIMESTAMPTZ NULL, + created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_repo_deploy_key_repo_id ON repo_deploy_key (repo_id); +CREATE INDEX IF NOT EXISTS idx_repo_deploy_key_repo_created ON repo_deploy_key (repo_id, created_at DESC); + +-- models/repos/repo_invitations.rs → repo_invitation +CREATE TABLE IF NOT EXISTS repo_invitation ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + email TEXT NOT NULL, + role TEXT NOT NULL, + token_hash TEXT NOT NULL, + invited_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + accepted_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + accepted_at TIMESTAMPTZ NULL, + revoked_at TIMESTAMPTZ NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_repo_invitation_repo_id ON repo_invitation (repo_id); +CREATE INDEX IF NOT EXISTS idx_repo_invitation_repo_created ON repo_invitation (repo_id, created_at DESC); + +-- models/repos/repo_members.rs → repo_member +CREATE TABLE IF NOT EXISTS repo_member ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + role TEXT NOT NULL, + status TEXT NOT NULL, + invited_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + joined_at TIMESTAMPTZ NULL, + last_active_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +, + CONSTRAINT uq_repo_member UNIQUE (repo_id, user_id) + +); +CREATE INDEX IF NOT EXISTS idx_repo_member_repo_id ON repo_member (repo_id); +CREATE INDEX IF NOT EXISTS idx_repo_member_user_id ON repo_member (user_id); +CREATE INDEX IF NOT EXISTS idx_repo_member_repo_created ON repo_member (repo_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_repo_member_user_created ON repo_member (user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_repo_member_status_created ON repo_member (status, created_at DESC); + +-- models/repos/repo_push_commit.rs → repo_push_commit +CREATE TABLE IF NOT EXISTS repo_push_commit ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + pusher_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + branch_name TEXT NOT NULL, + old_commit_sha TEXT NULL, + latest_commit_sha TEXT NOT NULL, + commit_shas TEXT[] NOT NULL, + commit_count INTEGER NOT NULL, + push_status TEXT NOT NULL, + pushed_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_repo_push_commit_repo_id ON repo_push_commit (repo_id); +CREATE INDEX IF NOT EXISTS idx_repo_push_commit_pusher_id ON repo_push_commit (pusher_id); +CREATE INDEX IF NOT EXISTS idx_repo_push_commit_repo_created ON repo_push_commit (repo_id, created_at DESC); + +-- models/repos/repo_push_lock.rs → repo_push_lock +CREATE TABLE IF NOT EXISTS repo_push_lock ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + pusher_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + ref_name TEXT NOT NULL, + status TEXT NOT NULL, + queue_position INTEGER NOT NULL, + queued_at TIMESTAMPTZ NOT NULL, + started_at TIMESTAMPTZ NULL, + finished_at TIMESTAMPTZ NULL, + storage_node_id UUID NULL, + lease_token TEXT NULL, + error_message TEXT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_repo_push_lock_repo_id ON repo_push_lock (repo_id); +CREATE INDEX IF NOT EXISTS idx_repo_push_lock_pusher_id ON repo_push_lock (pusher_id); +CREATE INDEX IF NOT EXISTS idx_repo_push_lock_storage_node_id ON repo_push_lock (storage_node_id); +CREATE INDEX IF NOT EXISTS idx_repo_push_lock_repo_created ON repo_push_lock (repo_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_repo_push_lock_status_created ON repo_push_lock (status, created_at DESC); + +-- models/repos/repo_stars.rs → repo_star +CREATE TABLE IF NOT EXISTS repo_star ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL +, + CONSTRAINT uq_repo_star UNIQUE (repo_id, user_id) + +); +CREATE INDEX IF NOT EXISTS idx_repo_star_repo_id ON repo_star (repo_id); +CREATE INDEX IF NOT EXISTS idx_repo_star_user_id ON repo_star (user_id); +CREATE INDEX IF NOT EXISTS idx_repo_star_repo_created ON repo_star (repo_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_repo_star_user_created ON repo_star (user_id, created_at DESC); + +-- models/repos/repo_stats.rs → repo_stats +CREATE TABLE IF NOT EXISTS repo_stats ( + PRIMARY KEY (repo_id), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + stars_count BIGINT NOT NULL, + watchers_count BIGINT NOT NULL, + forks_count BIGINT NOT NULL, + branches_count BIGINT NOT NULL, + tags_count BIGINT NOT NULL, + commits_count BIGINT NOT NULL, + releases_count BIGINT NOT NULL, + open_issues_count BIGINT NOT NULL, + open_pull_requests_count BIGINT NOT NULL, + size_bytes BIGINT NOT NULL, + last_push_at TIMESTAMPTZ NULL, + updated_at TIMESTAMPTZ NOT NULL + +); + +-- models/repos/repo_tags.rs → repo_tag +CREATE TABLE IF NOT EXISTS repo_tag ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + name TEXT NOT NULL, + target_commit_sha TEXT NOT NULL, + tagger_id UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + message TEXT NULL, + signed BOOLEAN NOT NULL, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_repo_tag_repo_id ON repo_tag (repo_id); +CREATE INDEX IF NOT EXISTS idx_repo_tag_tagger_id ON repo_tag (tagger_id); +CREATE INDEX IF NOT EXISTS idx_repo_tag_repo_created ON repo_tag (repo_id, created_at DESC); + +-- models/repos/repo_watches.rs → repo_watch +CREATE TABLE IF NOT EXISTS repo_watch ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + level TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +, + CONSTRAINT uq_repo_watch UNIQUE (repo_id, user_id) + +); +CREATE INDEX IF NOT EXISTS idx_repo_watch_repo_id ON repo_watch (repo_id); +CREATE INDEX IF NOT EXISTS idx_repo_watch_user_id ON repo_watch (user_id); +CREATE INDEX IF NOT EXISTS idx_repo_watch_repo_created ON repo_watch (repo_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_repo_watch_user_created ON repo_watch (user_id, created_at DESC); + +-- models/repos/repo_webhooks.rs → repo_webhook +CREATE TABLE IF NOT EXISTS repo_webhook ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + url TEXT NOT NULL, + secret_ciphertext TEXT NULL, + events TEXT[] NOT NULL, + active BOOLEAN NOT NULL, + last_delivery_status TEXT NULL, + last_delivery_at TIMESTAMPTZ NULL, + created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_repo_webhook_repo_id ON repo_webhook (repo_id); +CREATE INDEX IF NOT EXISTS idx_repo_webhook_repo_created ON repo_webhook (repo_id, created_at DESC); + +-- models/channels/channel_events.rs → channel_event +CREATE TABLE IF NOT EXISTS channel_event ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + actor_id UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + target_type TEXT NULL, + target_id UUID NULL, + old_value JSONB NULL, + new_value JSONB NULL, + metadata JSONB NULL, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_channel_event_channel_id ON channel_event (channel_id); +CREATE INDEX IF NOT EXISTS idx_channel_event_actor_id ON channel_event (actor_id); +CREATE INDEX IF NOT EXISTS idx_channel_event_target_id ON channel_event (target_id); + +-- models/channels/channel_invitations.rs → channel_invitation +CREATE TABLE IF NOT EXISTS channel_invitation ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + invited_user_id UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + email TEXT NULL, + role TEXT NOT NULL, + token_hash TEXT NOT NULL, + invited_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + accepted_at TIMESTAMPTZ NULL, + revoked_at TIMESTAMPTZ NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_channel_invitation_channel_id ON channel_invitation (channel_id); +CREATE INDEX IF NOT EXISTS idx_channel_invitation_workspace_id ON channel_invitation (workspace_id); +CREATE INDEX IF NOT EXISTS idx_channel_invitation_invited_user_id ON channel_invitation (invited_user_id); +CREATE INDEX IF NOT EXISTS idx_channel_invitation_ws_created ON channel_invitation (workspace_id, created_at DESC); + +-- models/channels/channel_member_roles.rs → channel_member_role +CREATE TABLE IF NOT EXISTS channel_member_role ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NULL, + permissions TEXT[] NOT NULL, + assignable BOOLEAN NOT NULL, + created_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_channel_member_role_channel_id ON channel_member_role (channel_id); + +-- models/channels/channel_repo_links.rs → channel_repo_link +CREATE TABLE IF NOT EXISTS channel_repo_link ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + link_type TEXT NOT NULL, + notify_events TEXT[] NOT NULL, + active BOOLEAN NOT NULL, + created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +, + CONSTRAINT uq_channel_repo_link UNIQUE (repo_id, channel_id) + +); +CREATE INDEX IF NOT EXISTS idx_channel_repo_link_channel_id ON channel_repo_link (channel_id); +CREATE INDEX IF NOT EXISTS idx_channel_repo_link_repo_id ON channel_repo_link (repo_id); +CREATE INDEX IF NOT EXISTS idx_channel_repo_link_repo_created ON channel_repo_link (repo_id, created_at DESC); + +-- models/channels/channel_slash_commands.rs → channel_slash_command +CREATE TABLE IF NOT EXISTS channel_slash_command ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NULL REFERENCES channel(id) ON DELETE CASCADE, + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + command TEXT NOT NULL, + description TEXT NULL, + request_url TEXT NOT NULL, + secret_ciphertext TEXT NULL, + scopes TEXT[] NOT NULL, + enabled BOOLEAN NOT NULL, + created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_channel_slash_command_channel_id ON channel_slash_command (channel_id); +CREATE INDEX IF NOT EXISTS idx_channel_slash_command_workspace_id ON channel_slash_command (workspace_id); +CREATE INDEX IF NOT EXISTS idx_channel_slash_command_ws_created ON channel_slash_command (workspace_id, created_at DESC); + +-- models/channels/channel_stats.rs → channel_stats +CREATE TABLE IF NOT EXISTS channel_stats ( + PRIMARY KEY (channel_id), + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + members_count BIGINT NOT NULL, + messages_count BIGINT NOT NULL, + threads_count BIGINT NOT NULL, + reactions_count BIGINT NOT NULL, + mentions_count BIGINT NOT NULL, + files_count BIGINT NOT NULL, + last_activity_at TIMESTAMPTZ NULL, + updated_at TIMESTAMPTZ NOT NULL + +); + +-- models/channels/channel_webhooks.rs → channel_webhook +CREATE TABLE IF NOT EXISTS channel_webhook ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + name TEXT NOT NULL, + url TEXT NOT NULL, + secret_ciphertext TEXT NULL, + events TEXT[] NOT NULL, + active BOOLEAN NOT NULL, + last_delivery_status TEXT NULL, + last_delivery_at TIMESTAMPTZ NULL, + created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_channel_webhook_channel_id ON channel_webhook (channel_id); + +-- models/channels/message.rs → message +CREATE TABLE IF NOT EXISTS message ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + thread_id UUID NULL, + reply_to_message_id UUID NULL, + message_type TEXT NOT NULL, + body TEXT NOT NULL, + metadata JSONB NULL, + pinned BOOLEAN NOT NULL, + system BOOLEAN NOT NULL, + edited_at TIMESTAMPTZ NULL, + deleted_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_message_channel_id ON message (channel_id); +CREATE INDEX IF NOT EXISTS idx_message_author_id ON message (author_id); +CREATE INDEX IF NOT EXISTS idx_message_thread_id ON message (thread_id); +CREATE INDEX IF NOT EXISTS idx_message_reply_to_message_id ON message (reply_to_message_id); +CREATE INDEX IF NOT EXISTS idx_message_deleted ON message (deleted_at) WHERE deleted_at IS NOT NULL; + +-- models/issues/issue_assignees.rs → issue_assignee +CREATE TABLE IF NOT EXISTS issue_assignee ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issue_id UUID NOT NULL REFERENCES issue(id) ON DELETE CASCADE, + assignee_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + assigned_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL +, + CONSTRAINT uq_issue_assignee UNIQUE (issue_id, assignee_id) + +); +CREATE INDEX IF NOT EXISTS idx_issue_assignee_issue_id ON issue_assignee (issue_id); +CREATE INDEX IF NOT EXISTS idx_issue_assignee_assignee_id ON issue_assignee (assignee_id); + +-- models/issues/issue_comments.rs → issue_comment +CREATE TABLE IF NOT EXISTS issue_comment ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issue_id UUID NOT NULL REFERENCES issue(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + body TEXT NOT NULL, + reply_to_comment_id UUID NULL, + edited_at TIMESTAMPTZ NULL, + deleted_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_issue_comment_issue_id ON issue_comment (issue_id); +CREATE INDEX IF NOT EXISTS idx_issue_comment_author_id ON issue_comment (author_id); +CREATE INDEX IF NOT EXISTS idx_issue_comment_reply_to_comment_id ON issue_comment (reply_to_comment_id); +CREATE INDEX IF NOT EXISTS idx_issue_comment_deleted ON issue_comment (deleted_at) WHERE deleted_at IS NOT NULL; + +-- models/issues/issue_events.rs → issue_event +CREATE TABLE IF NOT EXISTS issue_event ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issue_id UUID NOT NULL REFERENCES issue(id) ON DELETE CASCADE, + actor_id UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + old_value JSONB NULL, + new_value JSONB NULL, + metadata JSONB NULL, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_issue_event_issue_id ON issue_event (issue_id); +CREATE INDEX IF NOT EXISTS idx_issue_event_actor_id ON issue_event (actor_id); + +-- models/issues/issue_reminder.rs → issue_reminder +CREATE TABLE IF NOT EXISTS issue_reminder ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issue_id UUID NOT NULL REFERENCES issue(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + remind_at TIMESTAMPTZ NOT NULL, + message TEXT NULL, + sent_at TIMESTAMPTZ NULL, + canceled_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_issue_reminder_issue_id ON issue_reminder (issue_id); +CREATE INDEX IF NOT EXISTS idx_issue_reminder_user_id ON issue_reminder (user_id); +CREATE INDEX IF NOT EXISTS idx_issue_reminder_user_created ON issue_reminder (user_id, created_at DESC); + +-- models/issues/issue_stats.rs → issue_stats +CREATE TABLE IF NOT EXISTS issue_stats ( + PRIMARY KEY (issue_id), + issue_id UUID NOT NULL REFERENCES issue(id) ON DELETE CASCADE, + comments_count BIGINT NOT NULL, + reactions_count BIGINT NOT NULL, + assignees_count BIGINT NOT NULL, + labels_count BIGINT NOT NULL, + subscribers_count BIGINT NOT NULL, + last_commented_at TIMESTAMPTZ NULL, + updated_at TIMESTAMPTZ NOT NULL + +); + +-- models/issues/issue_subscribers.rs → issue_subscriber +CREATE TABLE IF NOT EXISTS issue_subscriber ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issue_id UUID NOT NULL REFERENCES issue(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + reason TEXT NOT NULL, + muted BOOLEAN NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +, + CONSTRAINT uq_issue_subscriber UNIQUE (issue_id, user_id) + +); +CREATE INDEX IF NOT EXISTS idx_issue_subscriber_issue_id ON issue_subscriber (issue_id); +CREATE INDEX IF NOT EXISTS idx_issue_subscriber_user_id ON issue_subscriber (user_id); +CREATE INDEX IF NOT EXISTS idx_issue_subscriber_user_created ON issue_subscriber (user_id, created_at DESC); + +-- models/issues/issue_label_relations.rs → issue_label_relation +CREATE TABLE IF NOT EXISTS issue_label_relation ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issue_id UUID NOT NULL REFERENCES issue(id) ON DELETE CASCADE, + label_id UUID NOT NULL REFERENCES issue_label(id) ON DELETE CASCADE, + created_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_issue_label_relation_issue_id ON issue_label_relation (issue_id); +CREATE INDEX IF NOT EXISTS idx_issue_label_relation_label_id ON issue_label_relation (label_id); + +-- models/agents/agent_executions.rs → agent_execution +CREATE TABLE IF NOT EXISTS agent_execution ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id UUID NOT NULL REFERENCES agent(id) ON DELETE CASCADE, + agent_version_id UUID NULL REFERENCES agent_version(id) ON DELETE CASCADE, + workspace_id UUID NULL REFERENCES workspace(id) ON DELETE CASCADE, + repo_id UUID NULL REFERENCES repo(id) ON DELETE CASCADE, + issue_id UUID NULL REFERENCES issue(id) ON DELETE CASCADE, + pull_request_id UUID NULL REFERENCES pull_request(id) ON DELETE CASCADE, + triggered_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + trigger_type TEXT NOT NULL, + status TEXT NOT NULL, + input JSONB NULL, + output JSONB NULL, + error_message TEXT NULL, + started_at TIMESTAMPTZ NULL, + finished_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_agent_execution_agent_id ON agent_execution (agent_id); +CREATE INDEX IF NOT EXISTS idx_agent_execution_agent_version_id ON agent_execution (agent_version_id); +CREATE INDEX IF NOT EXISTS idx_agent_execution_workspace_id ON agent_execution (workspace_id); +CREATE INDEX IF NOT EXISTS idx_agent_execution_repo_id ON agent_execution (repo_id); +CREATE INDEX IF NOT EXISTS idx_agent_execution_issue_id ON agent_execution (issue_id); +CREATE INDEX IF NOT EXISTS idx_agent_execution_pull_request_id ON agent_execution (pull_request_id); +CREATE INDEX IF NOT EXISTS idx_agent_execution_repo_created ON agent_execution (repo_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_agent_execution_ws_created ON agent_execution (workspace_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_agent_execution_status_created ON agent_execution (status, created_at DESC); + +-- models/issues/issue_pr_relations.rs → issue_pr_relation +CREATE TABLE IF NOT EXISTS issue_pr_relation ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issue_id UUID NOT NULL REFERENCES issue(id) ON DELETE CASCADE, + pull_request_id UUID NOT NULL REFERENCES pull_request(id) ON DELETE CASCADE, + relation_type TEXT NOT NULL, + created_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL +, + CONSTRAINT uq_issue_pr_relation UNIQUE (issue_id, pull_request_id) + +); +CREATE INDEX IF NOT EXISTS idx_issue_pr_relation_issue_id ON issue_pr_relation (issue_id); +CREATE INDEX IF NOT EXISTS idx_issue_pr_relation_pull_request_id ON issue_pr_relation (pull_request_id); + +-- models/prs/pr_assignees.rs → pr_assignee +CREATE TABLE IF NOT EXISTS pr_assignee ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pull_request_id UUID NOT NULL REFERENCES pull_request(id) ON DELETE CASCADE, + assignee_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + assigned_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL +, + CONSTRAINT uq_pr_assignee UNIQUE (pull_request_id, assignee_id) + +); +CREATE INDEX IF NOT EXISTS idx_pr_assignee_pull_request_id ON pr_assignee (pull_request_id); +CREATE INDEX IF NOT EXISTS idx_pr_assignee_assignee_id ON pr_assignee (assignee_id); + +-- models/prs/pr_check_runs.rs → pr_check_run +CREATE TABLE IF NOT EXISTS pr_check_run ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pull_request_id UUID NOT NULL REFERENCES pull_request(id) ON DELETE CASCADE, + commit_sha TEXT NOT NULL, + name TEXT NOT NULL, + status TEXT NOT NULL, + conclusion TEXT NULL, + details_url TEXT NULL, + external_id TEXT NULL, + started_at TIMESTAMPTZ NULL, + completed_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_pr_check_run_pull_request_id ON pr_check_run (pull_request_id); +CREATE INDEX IF NOT EXISTS idx_pr_check_run_external_id ON pr_check_run (external_id); +CREATE INDEX IF NOT EXISTS idx_pr_check_run_status_created ON pr_check_run (status, created_at DESC); + +-- models/prs/pr_commits.rs → pr_commit +CREATE TABLE IF NOT EXISTS pr_commit ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pull_request_id UUID NOT NULL REFERENCES pull_request(id) ON DELETE CASCADE, + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + commit_sha TEXT NOT NULL, + position INTEGER NOT NULL, + authored_at TIMESTAMPTZ NULL, + committed_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_pr_commit_pull_request_id ON pr_commit (pull_request_id); +CREATE INDEX IF NOT EXISTS idx_pr_commit_repo_id ON pr_commit (repo_id); +CREATE INDEX IF NOT EXISTS idx_pr_commit_repo_created ON pr_commit (repo_id, created_at DESC); + +-- models/prs/pr_events.rs → pr_event +CREATE TABLE IF NOT EXISTS pr_event ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pull_request_id UUID NOT NULL REFERENCES pull_request(id) ON DELETE CASCADE, + actor_id UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + old_value JSONB NULL, + new_value JSONB NULL, + metadata JSONB NULL, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_pr_event_pull_request_id ON pr_event (pull_request_id); +CREATE INDEX IF NOT EXISTS idx_pr_event_actor_id ON pr_event (actor_id); + +-- models/prs/pr_files.rs → pr_file +CREATE TABLE IF NOT EXISTS pr_file ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pull_request_id UUID NOT NULL REFERENCES pull_request(id) ON DELETE CASCADE, + path TEXT NOT NULL, + old_path TEXT NULL, + status TEXT NOT NULL, + additions INTEGER NOT NULL, + deletions INTEGER NOT NULL, + changes INTEGER NOT NULL, + patch TEXT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_pr_file_pull_request_id ON pr_file (pull_request_id); +CREATE INDEX IF NOT EXISTS idx_pr_file_status_created ON pr_file (status, created_at DESC); + +-- models/prs/pr_label_relations.rs → pr_label_relation +CREATE TABLE IF NOT EXISTS pr_label_relation ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pull_request_id UUID NOT NULL REFERENCES pull_request(id) ON DELETE CASCADE, + label_id UUID NOT NULL REFERENCES pr_label(id) ON DELETE CASCADE, + created_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_pr_label_relation_pull_request_id ON pr_label_relation (pull_request_id); +CREATE INDEX IF NOT EXISTS idx_pr_label_relation_label_id ON pr_label_relation (label_id); + +-- models/prs/pr_merge_strategy.rs → pr_merge_strategy +CREATE TABLE IF NOT EXISTS pr_merge_strategy ( + PRIMARY KEY (pull_request_id), + pull_request_id UUID NOT NULL REFERENCES pull_request(id) ON DELETE CASCADE, + strategy TEXT NOT NULL, + auto_merge BOOLEAN NOT NULL, + squash_title TEXT NULL, + squash_message TEXT NULL, + delete_source_branch BOOLEAN NOT NULL, + merge_when_checks_pass BOOLEAN NOT NULL, + selected_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); + +-- models/prs/pr_reactions.rs → pr_reaction +CREATE TABLE IF NOT EXISTS pr_reaction ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pull_request_id UUID NOT NULL REFERENCES pull_request(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + content TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id UUID NULL, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_pr_reaction_pull_request_id ON pr_reaction (pull_request_id); +CREATE INDEX IF NOT EXISTS idx_pr_reaction_user_id ON pr_reaction (user_id); +CREATE INDEX IF NOT EXISTS idx_pr_reaction_target_id ON pr_reaction (target_id); +CREATE INDEX IF NOT EXISTS idx_pr_reaction_user_created ON pr_reaction (user_id, created_at DESC); + +-- models/prs/pr_status.rs → pr_status +CREATE TABLE IF NOT EXISTS pr_status ( + PRIMARY KEY (pull_request_id), + pull_request_id UUID NOT NULL REFERENCES pull_request(id) ON DELETE CASCADE, + head_commit_sha TEXT NOT NULL, + checks_state TEXT NOT NULL, + mergeable_state TEXT NOT NULL, + conflicts BOOLEAN NOT NULL, + approvals_count INTEGER NOT NULL, + requested_reviews_count INTEGER NOT NULL, + changed_files_count INTEGER NOT NULL, + additions_count INTEGER NOT NULL, + deletions_count INTEGER NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); + +-- models/prs/pr_subscriptions.rs → pr_subscription +CREATE TABLE IF NOT EXISTS pr_subscription ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pull_request_id UUID NOT NULL REFERENCES pull_request(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + reason TEXT NOT NULL, + muted BOOLEAN NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +, + CONSTRAINT uq_pr_subscription UNIQUE (pull_request_id, user_id) + +); +CREATE INDEX IF NOT EXISTS idx_pr_subscription_pull_request_id ON pr_subscription (pull_request_id); +CREATE INDEX IF NOT EXISTS idx_pr_subscription_user_id ON pr_subscription (user_id); +CREATE INDEX IF NOT EXISTS idx_pr_subscription_user_created ON pr_subscription (user_id, created_at DESC); + +-- models/issues/issue_commit_relations.rs → issue_commit_relation +CREATE TABLE IF NOT EXISTS issue_commit_relation ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issue_id UUID NOT NULL REFERENCES issue(id) ON DELETE CASCADE, + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + push_commit_id UUID NULL REFERENCES repo_push_commit(id) ON DELETE CASCADE, + commit_sha TEXT NOT NULL, + relation_type TEXT NOT NULL, + created_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_issue_commit_relation_issue_id ON issue_commit_relation (issue_id); +CREATE INDEX IF NOT EXISTS idx_issue_commit_relation_repo_id ON issue_commit_relation (repo_id); +CREATE INDEX IF NOT EXISTS idx_issue_commit_relation_push_commit_id ON issue_commit_relation (push_commit_id); +CREATE INDEX IF NOT EXISTS idx_issue_commit_relation_repo_created ON issue_commit_relation (repo_id, created_at DESC); + +-- models/repos/repo_branches.rs → repo_branch +CREATE TABLE IF NOT EXISTS repo_branch ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + name TEXT NOT NULL, + commit_sha TEXT NOT NULL, + protected BOOLEAN NOT NULL, + default_branch BOOLEAN NOT NULL, + created_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + last_push_id UUID NULL REFERENCES repo_push_commit(id) ON DELETE CASCADE, + last_push_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_repo_branch_repo_id ON repo_branch (repo_id); +CREATE INDEX IF NOT EXISTS idx_repo_branch_last_push_id ON repo_branch (last_push_id); +CREATE INDEX IF NOT EXISTS idx_repo_branch_repo_created ON repo_branch (repo_id, created_at DESC); + +-- models/repos/repo_commit_comments.rs → repo_commit_comment +CREATE TABLE IF NOT EXISTS repo_commit_comment ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + push_commit_id UUID NOT NULL REFERENCES repo_push_commit(id) ON DELETE CASCADE, + commit_sha TEXT NOT NULL, + author_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + body TEXT NOT NULL, + path TEXT NULL, + line INTEGER NULL, + resolved BOOLEAN NOT NULL, + resolved_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + resolved_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ NULL + +); +CREATE INDEX IF NOT EXISTS idx_repo_commit_comment_repo_id ON repo_commit_comment (repo_id); +CREATE INDEX IF NOT EXISTS idx_repo_commit_comment_push_commit_id ON repo_commit_comment (push_commit_id); +CREATE INDEX IF NOT EXISTS idx_repo_commit_comment_author_id ON repo_commit_comment (author_id); +CREATE INDEX IF NOT EXISTS idx_repo_commit_comment_repo_created ON repo_commit_comment (repo_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_repo_commit_comment_deleted ON repo_commit_comment (deleted_at) WHERE deleted_at IS NOT NULL; + +-- models/repos/repo_commit_statuses.rs → repo_commit_status +CREATE TABLE IF NOT EXISTS repo_commit_status ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + push_commit_id UUID NOT NULL REFERENCES repo_push_commit(id) ON DELETE CASCADE, + latest_commit_sha TEXT NOT NULL, + context TEXT NOT NULL, + state TEXT NOT NULL, + target_url TEXT NULL, + description TEXT NULL, + reported_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + reported_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_repo_commit_status_repo_id ON repo_commit_status (repo_id); +CREATE INDEX IF NOT EXISTS idx_repo_commit_status_push_commit_id ON repo_commit_status (push_commit_id); +CREATE INDEX IF NOT EXISTS idx_repo_commit_status_repo_created ON repo_commit_status (repo_id, created_at DESC); + +-- models/repos/repo_releases.rs → repo_release +CREATE TABLE IF NOT EXISTS repo_release ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + tag_id UUID NULL REFERENCES repo_tag(id) ON DELETE CASCADE, + tag_name TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NULL, + draft BOOLEAN NOT NULL, + prerelease BOOLEAN NOT NULL, + author_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + published_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ NULL + +); +CREATE INDEX IF NOT EXISTS idx_repo_release_repo_id ON repo_release (repo_id); +CREATE INDEX IF NOT EXISTS idx_repo_release_tag_id ON repo_release (tag_id); +CREATE INDEX IF NOT EXISTS idx_repo_release_author_id ON repo_release (author_id); +CREATE INDEX IF NOT EXISTS idx_repo_release_repo_created ON repo_release (repo_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_repo_release_deleted ON repo_release (deleted_at) WHERE deleted_at IS NOT NULL; + +-- models/channels/channel_members.rs → channel_member +CREATE TABLE IF NOT EXISTS channel_member ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + role TEXT NOT NULL, + status TEXT NOT NULL, + muted BOOLEAN NOT NULL, + pinned BOOLEAN NOT NULL, + last_read_message_id UUID NULL REFERENCES message(id) ON DELETE CASCADE, + last_read_at TIMESTAMPTZ NULL, + joined_at TIMESTAMPTZ NULL, + left_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +, + CONSTRAINT uq_channel_member UNIQUE (channel_id, user_id) + +); +CREATE INDEX IF NOT EXISTS idx_channel_member_channel_id ON channel_member (channel_id); +CREATE INDEX IF NOT EXISTS idx_channel_member_user_id ON channel_member (user_id); +CREATE INDEX IF NOT EXISTS idx_channel_member_last_read_message_id ON channel_member (last_read_message_id); +CREATE INDEX IF NOT EXISTS idx_channel_member_user_created ON channel_member (user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_channel_member_status_created ON channel_member (status, created_at DESC); + +-- models/channels/message_bookmarks.rs → message_bookmark +CREATE TABLE IF NOT EXISTS message_bookmark ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + note TEXT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_message_bookmark_message_id ON message_bookmark (message_id); +CREATE INDEX IF NOT EXISTS idx_message_bookmark_channel_id ON message_bookmark (channel_id); +CREATE INDEX IF NOT EXISTS idx_message_bookmark_user_id ON message_bookmark (user_id); +CREATE INDEX IF NOT EXISTS idx_message_bookmark_user_created ON message_bookmark (user_id, created_at DESC); + +-- models/channels/message_mentions.rs → message_mention +CREATE TABLE IF NOT EXISTS message_mention ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + mentioned_user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + mentioned_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + read_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_message_mention_message_id ON message_mention (message_id); +CREATE INDEX IF NOT EXISTS idx_message_mention_channel_id ON message_mention (channel_id); +CREATE INDEX IF NOT EXISTS idx_message_mention_mentioned_user_id ON message_mention (mentioned_user_id); + +-- models/channels/message_reactions.rs → message_reaction +CREATE TABLE IF NOT EXISTS message_reaction ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_message_reaction_message_id ON message_reaction (message_id); +CREATE INDEX IF NOT EXISTS idx_message_reaction_channel_id ON message_reaction (channel_id); +CREATE INDEX IF NOT EXISTS idx_message_reaction_user_id ON message_reaction (user_id); +CREATE INDEX IF NOT EXISTS idx_message_reaction_user_created ON message_reaction (user_id, created_at DESC); + +-- models/channels/message_threads.rs → message_thread +CREATE TABLE IF NOT EXISTS message_thread ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + root_message_id UUID NOT NULL, + created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + replies_count BIGINT NOT NULL, + participants_count BIGINT NOT NULL, + last_reply_message_id UUID NULL REFERENCES message(id) ON DELETE CASCADE, + last_reply_at TIMESTAMPTZ NULL, + resolved BOOLEAN NOT NULL, + resolved_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + resolved_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_message_thread_channel_id ON message_thread (channel_id); +CREATE INDEX IF NOT EXISTS idx_message_thread_root_message_id ON message_thread (root_message_id); +CREATE INDEX IF NOT EXISTS idx_message_thread_last_reply_message_id ON message_thread (last_reply_message_id); + +-- models/conversations/conversation.rs → conversation +CREATE TABLE IF NOT EXISTS conversation ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NULL REFERENCES workspace(id) ON DELETE CASCADE, + repo_id UUID NULL REFERENCES repo(id) ON DELETE CASCADE, + issue_id UUID NULL REFERENCES issue(id) ON DELETE CASCADE, + pull_request_id UUID NULL REFERENCES pull_request(id) ON DELETE CASCADE, + agent_id UUID NULL REFERENCES agent(id) ON DELETE CASCADE, + created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT NULL, + conversation_type TEXT NOT NULL, + status TEXT NOT NULL, + visibility TEXT NOT NULL, + pinned BOOLEAN NOT NULL, + archived BOOLEAN NOT NULL, + metadata JSONB NULL, + last_message_id UUID NULL REFERENCES message(id) ON DELETE CASCADE, + last_message_at TIMESTAMPTZ NULL, + archived_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ NULL + +); +CREATE INDEX IF NOT EXISTS idx_conversation_workspace_id ON conversation (workspace_id); +CREATE INDEX IF NOT EXISTS idx_conversation_repo_id ON conversation (repo_id); +CREATE INDEX IF NOT EXISTS idx_conversation_issue_id ON conversation (issue_id); +CREATE INDEX IF NOT EXISTS idx_conversation_pull_request_id ON conversation (pull_request_id); +CREATE INDEX IF NOT EXISTS idx_conversation_agent_id ON conversation (agent_id); +CREATE INDEX IF NOT EXISTS idx_conversation_last_message_id ON conversation (last_message_id); +CREATE INDEX IF NOT EXISTS idx_conversation_repo_created ON conversation (repo_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_conversation_ws_created ON conversation (workspace_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_conversation_status_created ON conversation (status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_conversation_deleted ON conversation (deleted_at) WHERE deleted_at IS NOT NULL; + +-- models/notifications/notification.rs → notification +CREATE TABLE IF NOT EXISTS notification ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + actor_id UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + workspace_id UUID NULL REFERENCES workspace(id) ON DELETE CASCADE, + repo_id UUID NULL REFERENCES repo(id) ON DELETE CASCADE, + issue_id UUID NULL REFERENCES issue(id) ON DELETE CASCADE, + pull_request_id UUID NULL REFERENCES pull_request(id) ON DELETE CASCADE, + channel_id UUID NULL REFERENCES channel(id) ON DELETE CASCADE, + message_id UUID NULL REFERENCES message(id) ON DELETE CASCADE, + notification_type TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NULL, + target_type TEXT NULL, + target_id UUID NULL, + action_url TEXT NULL, + priority TEXT NOT NULL, + read_at TIMESTAMPTZ NULL, + dismissed_at TIMESTAMPTZ NULL, + metadata JSONB NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ NULL + +); +CREATE INDEX IF NOT EXISTS idx_notification_user_id ON notification (user_id); +CREATE INDEX IF NOT EXISTS idx_notification_actor_id ON notification (actor_id); +CREATE INDEX IF NOT EXISTS idx_notification_workspace_id ON notification (workspace_id); +CREATE INDEX IF NOT EXISTS idx_notification_repo_id ON notification (repo_id); +CREATE INDEX IF NOT EXISTS idx_notification_issue_id ON notification (issue_id); +CREATE INDEX IF NOT EXISTS idx_notification_pull_request_id ON notification (pull_request_id); +CREATE INDEX IF NOT EXISTS idx_notification_channel_id ON notification (channel_id); +CREATE INDEX IF NOT EXISTS idx_notification_message_id ON notification (message_id); +CREATE INDEX IF NOT EXISTS idx_notification_target_id ON notification (target_id); +CREATE INDEX IF NOT EXISTS idx_notification_repo_created ON notification (repo_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notification_ws_created ON notification (workspace_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notification_user_created ON notification (user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notification_deleted ON notification (deleted_at) WHERE deleted_at IS NOT NULL; + +-- models/agents/agent_feedback.rs → agent_feedback +CREATE TABLE IF NOT EXISTS agent_feedback ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id UUID NOT NULL REFERENCES agent(id) ON DELETE CASCADE, + execution_id UUID NULL REFERENCES agent_execution(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + rating INTEGER NOT NULL, + feedback_type TEXT NOT NULL, + comment TEXT NULL, + metadata JSONB NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_agent_feedback_agent_id ON agent_feedback (agent_id); +CREATE INDEX IF NOT EXISTS idx_agent_feedback_execution_id ON agent_feedback (execution_id); +CREATE INDEX IF NOT EXISTS idx_agent_feedback_user_id ON agent_feedback (user_id); +CREATE INDEX IF NOT EXISTS idx_agent_feedback_user_created ON agent_feedback (user_id, created_at DESC); + +-- models/conversations/conversation_attachments.rs → conversation_attachment +CREATE TABLE IF NOT EXISTS conversation_attachment ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID NOT NULL REFERENCES conversation(id) ON DELETE CASCADE, + message_id UUID NULL REFERENCES message(id) ON DELETE CASCADE, + file_id UUID NULL REFERENCES conversation_attachment(id) ON DELETE CASCADE, + uploaded_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + name TEXT NOT NULL, + content_type TEXT NULL, + size_bytes BIGINT NOT NULL, + storage_path TEXT NULL, + url TEXT NULL, + metadata JSONB NULL, + created_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ NULL + +); +CREATE INDEX IF NOT EXISTS idx_conversation_attachment_conversation_id ON conversation_attachment (conversation_id); +CREATE INDEX IF NOT EXISTS idx_conversation_attachment_message_id ON conversation_attachment (message_id); +CREATE INDEX IF NOT EXISTS idx_conversation_attachment_file_id ON conversation_attachment (file_id); +CREATE INDEX IF NOT EXISTS idx_conversation_attachment_deleted ON conversation_attachment (deleted_at) WHERE deleted_at IS NOT NULL; + +-- models/conversations/conversation_bookmarks.rs → conversation_bookmark +CREATE TABLE IF NOT EXISTS conversation_bookmark ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID NOT NULL REFERENCES conversation(id) ON DELETE CASCADE, + message_id UUID NULL REFERENCES message(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + title TEXT NULL, + note TEXT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_conversation_bookmark_conversation_id ON conversation_bookmark (conversation_id); +CREATE INDEX IF NOT EXISTS idx_conversation_bookmark_message_id ON conversation_bookmark (message_id); +CREATE INDEX IF NOT EXISTS idx_conversation_bookmark_user_id ON conversation_bookmark (user_id); +CREATE INDEX IF NOT EXISTS idx_conversation_bookmark_user_created ON conversation_bookmark (user_id, created_at DESC); + +-- models/conversations/conversation_messages.rs → conversation_message +CREATE TABLE IF NOT EXISTS conversation_message ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID NOT NULL REFERENCES conversation(id) ON DELETE CASCADE, + parent_message_id UUID NULL, + author_id UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + agent_id UUID NULL REFERENCES agent(id) ON DELETE CASCADE, + ai_model_id UUID NULL REFERENCES ai_model(id) ON DELETE CASCADE, + role TEXT NOT NULL, + message_type TEXT NOT NULL, + content TEXT NOT NULL, + content_format TEXT NOT NULL, + status TEXT NOT NULL, + metadata JSONB NULL, + token_input_count INTEGER NULL, + token_output_count INTEGER NULL, + edited_at TIMESTAMPTZ NULL, + deleted_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_conversation_message_conversation_id ON conversation_message (conversation_id); +CREATE INDEX IF NOT EXISTS idx_conversation_message_parent_message_id ON conversation_message (parent_message_id); +CREATE INDEX IF NOT EXISTS idx_conversation_message_author_id ON conversation_message (author_id); +CREATE INDEX IF NOT EXISTS idx_conversation_message_agent_id ON conversation_message (agent_id); +CREATE INDEX IF NOT EXISTS idx_conversation_message_ai_model_id ON conversation_message (ai_model_id); +CREATE INDEX IF NOT EXISTS idx_conversation_message_status_created ON conversation_message (status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_conversation_message_deleted ON conversation_message (deleted_at) WHERE deleted_at IS NOT NULL; + +-- models/conversations/conversation_participants.rs → conversation_participant +CREATE TABLE IF NOT EXISTS conversation_participant ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID NOT NULL REFERENCES conversation(id) ON DELETE CASCADE, + user_id UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + agent_id UUID NULL REFERENCES agent(id) ON DELETE CASCADE, + role TEXT NOT NULL, + participant_type TEXT NOT NULL, + status TEXT NOT NULL, + muted BOOLEAN NOT NULL, + last_read_message_id UUID NULL REFERENCES message(id) ON DELETE CASCADE, + last_read_at TIMESTAMPTZ NULL, + joined_at TIMESTAMPTZ NULL, + left_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_conversation_participant_conversation_id ON conversation_participant (conversation_id); +CREATE INDEX IF NOT EXISTS idx_conversation_participant_user_id ON conversation_participant (user_id); +CREATE INDEX IF NOT EXISTS idx_conversation_participant_agent_id ON conversation_participant (agent_id); +CREATE INDEX IF NOT EXISTS idx_conversation_participant_last_read_message_id ON conversation_participant (last_read_message_id); +CREATE INDEX IF NOT EXISTS idx_conversation_participant_user_created ON conversation_participant (user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_conversation_participant_status_created ON conversation_participant (status, created_at DESC); + +-- models/conversations/conversation_tool_calls.rs → conversation_tool_call +CREATE TABLE IF NOT EXISTS conversation_tool_call ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID NOT NULL REFERENCES conversation(id) ON DELETE CASCADE, + message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE, + agent_id UUID NULL REFERENCES agent(id) ON DELETE CASCADE, + tool_name TEXT NOT NULL, + call_id TEXT NULL, + status TEXT NOT NULL, + arguments JSONB NULL, + result JSONB NULL, + error_message TEXT NULL, + started_at TIMESTAMPTZ NULL, + finished_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_conversation_tool_call_conversation_id ON conversation_tool_call (conversation_id); +CREATE INDEX IF NOT EXISTS idx_conversation_tool_call_message_id ON conversation_tool_call (message_id); +CREATE INDEX IF NOT EXISTS idx_conversation_tool_call_agent_id ON conversation_tool_call (agent_id); +CREATE INDEX IF NOT EXISTS idx_conversation_tool_call_call_id ON conversation_tool_call (call_id); +CREATE INDEX IF NOT EXISTS idx_conversation_tool_call_status_created ON conversation_tool_call (status, created_at DESC); + +-- models/conversations/conversation_summaries.rs → conversation_summary +CREATE TABLE IF NOT EXISTS conversation_summary ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID NOT NULL REFERENCES conversation(id) ON DELETE CASCADE, + ai_model_id UUID NULL REFERENCES ai_model(id) ON DELETE CASCADE, + generated_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + from_message_id UUID NULL REFERENCES conversation_message(id) ON DELETE CASCADE, + to_message_id UUID NULL REFERENCES conversation_message(id) ON DELETE CASCADE, + summary_type TEXT NOT NULL, + content TEXT NOT NULL, + token_count INTEGER NULL, + metadata JSONB NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); +CREATE INDEX IF NOT EXISTS idx_conversation_summary_conversation_id ON conversation_summary (conversation_id); +CREATE INDEX IF NOT EXISTS idx_conversation_summary_ai_model_id ON conversation_summary (ai_model_id); +CREATE INDEX IF NOT EXISTS idx_conversation_summary_from_message_id ON conversation_summary (from_message_id); +CREATE INDEX IF NOT EXISTS idx_conversation_summary_to_message_id ON conversation_summary (to_message_id); + + +-- PHASE B: Deferred FKs (circular / self-referencing) + +ALTER TABLE agent_execution_step ADD CONSTRAINT fk_agent_execution_step_execution_id + FOREIGN KEY (execution_id) REFERENCES agent_execution(id) ON DELETE CASCADE; + +ALTER TABLE agent ADD CONSTRAINT fk_agent_current_version_id + FOREIGN KEY (current_version_id) REFERENCES agent_version(id) ON DELETE CASCADE; + +ALTER TABLE channel ADD CONSTRAINT fk_channel_last_message_id + FOREIGN KEY (last_message_id) REFERENCES message(id) ON DELETE CASCADE; + +ALTER TABLE message ADD CONSTRAINT fk_message_thread_id + FOREIGN KEY (thread_id) REFERENCES message_thread(id) ON DELETE CASCADE; + +ALTER TABLE message ADD CONSTRAINT fk_message_reply_to_message_id + FOREIGN KEY (reply_to_message_id) REFERENCES message(id) ON DELETE CASCADE; + +ALTER TABLE issue_comment ADD CONSTRAINT fk_issue_comment_reply_to_comment_id + FOREIGN KEY (reply_to_comment_id) REFERENCES issue_comment(id) ON DELETE CASCADE; + +ALTER TABLE message_thread ADD CONSTRAINT fk_message_thread_root_message_id + FOREIGN KEY (root_message_id) REFERENCES message(id) ON DELETE CASCADE; + +ALTER TABLE conversation_message ADD CONSTRAINT fk_conversation_message_parent_message_id + FOREIGN KEY (parent_message_id) REFERENCES conversation_message(id) ON DELETE CASCADE; + +COMMIT; \ No newline at end of file diff --git a/migrate/002_triggers.sql b/migrate/002_triggers.sql new file mode 100644 index 0000000..ad38c5f --- /dev/null +++ b/migrate/002_triggers.sql @@ -0,0 +1,667 @@ +-- ============================================================ +-- Migration: 002_triggers.sql +-- Automated: updated_at, deleted_at, event recording, audit, security, stats +-- +-- Usage: +-- Application sets session variable before writes: +-- SET LOCAL app.current_user_id = '00000000-0000-0000-0000-000000000001'; +-- Triggers read it for actor_id in events / audit / security logs. +-- ============================================================ + +-- ============================================================ +-- 1. set_updated_at — auto-refresh updated_at on any UPDATE +-- Applied to ALL tables with an updated_at column. +-- ============================================================ +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Tables with updated_at (generated from models/) +DROP TRIGGER IF EXISTS trg_agent_updated_at ON agent; +CREATE TRIGGER trg_agent_updated_at BEFORE UPDATE ON agent FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_agent_event_subscription_updated_at ON agent_event_subscription; +CREATE TRIGGER trg_agent_event_subscription_updated_at BEFORE UPDATE ON agent_event_subscription FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_agent_execution_updated_at ON agent_execution; +CREATE TRIGGER trg_agent_execution_updated_at BEFORE UPDATE ON agent_execution FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_agent_feedback_updated_at ON agent_feedback; +CREATE TRIGGER trg_agent_feedback_updated_at BEFORE UPDATE ON agent_feedback FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_agent_schedule_updated_at ON agent_schedule; +CREATE TRIGGER trg_agent_schedule_updated_at BEFORE UPDATE ON agent_schedule FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_agent_version_updated_at ON agent_version; +CREATE TRIGGER trg_agent_version_updated_at BEFORE UPDATE ON agent_version FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_agent_workspace_binding_updated_at ON agent_workspace_binding; +CREATE TRIGGER trg_agent_workspace_binding_updated_at BEFORE UPDATE ON agent_workspace_binding FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_ai_model_updated_at ON ai_model; +CREATE TRIGGER trg_ai_model_updated_at BEFORE UPDATE ON ai_model FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_ai_model_capability_updated_at ON ai_model_capability; +CREATE TRIGGER trg_ai_model_capability_updated_at BEFORE UPDATE ON ai_model_capability FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_ai_model_version_updated_at ON ai_model_version; +CREATE TRIGGER trg_ai_model_version_updated_at BEFORE UPDATE ON ai_model_version FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_channel_updated_at ON channel; +CREATE TRIGGER trg_channel_updated_at BEFORE UPDATE ON channel FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_channel_member_updated_at ON channel_member; +CREATE TRIGGER trg_channel_member_updated_at BEFORE UPDATE ON channel_member FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_channel_member_role_updated_at ON channel_member_role; +CREATE TRIGGER trg_channel_member_role_updated_at BEFORE UPDATE ON channel_member_role FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_channel_repo_link_updated_at ON channel_repo_link; +CREATE TRIGGER trg_channel_repo_link_updated_at BEFORE UPDATE ON channel_repo_link FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_channel_slash_command_updated_at ON channel_slash_command; +CREATE TRIGGER trg_channel_slash_command_updated_at BEFORE UPDATE ON channel_slash_command FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_channel_stats_updated_at ON channel_stats; +CREATE TRIGGER trg_channel_stats_updated_at BEFORE UPDATE ON channel_stats FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_channel_webhook_updated_at ON channel_webhook; +CREATE TRIGGER trg_channel_webhook_updated_at BEFORE UPDATE ON channel_webhook FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_conversation_updated_at ON conversation; +CREATE TRIGGER trg_conversation_updated_at BEFORE UPDATE ON conversation FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_conversation_bookmark_updated_at ON conversation_bookmark; +CREATE TRIGGER trg_conversation_bookmark_updated_at BEFORE UPDATE ON conversation_bookmark FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_conversation_message_updated_at ON conversation_message; +CREATE TRIGGER trg_conversation_message_updated_at BEFORE UPDATE ON conversation_message FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_conversation_participant_updated_at ON conversation_participant; +CREATE TRIGGER trg_conversation_participant_updated_at BEFORE UPDATE ON conversation_participant FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_conversation_summary_updated_at ON conversation_summary; +CREATE TRIGGER trg_conversation_summary_updated_at BEFORE UPDATE ON conversation_summary FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_conversation_tool_call_updated_at ON conversation_tool_call; +CREATE TRIGGER trg_conversation_tool_call_updated_at BEFORE UPDATE ON conversation_tool_call FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_issue_updated_at ON issue; +CREATE TRIGGER trg_issue_updated_at BEFORE UPDATE ON issue FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_issue_comment_updated_at ON issue_comment; +CREATE TRIGGER trg_issue_comment_updated_at BEFORE UPDATE ON issue_comment FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_issue_label_updated_at ON issue_label; +CREATE TRIGGER trg_issue_label_updated_at BEFORE UPDATE ON issue_label FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_issue_milestone_updated_at ON issue_milestone; +CREATE TRIGGER trg_issue_milestone_updated_at BEFORE UPDATE ON issue_milestone FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_issue_reminder_updated_at ON issue_reminder; +CREATE TRIGGER trg_issue_reminder_updated_at BEFORE UPDATE ON issue_reminder FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_issue_stats_updated_at ON issue_stats; +CREATE TRIGGER trg_issue_stats_updated_at BEFORE UPDATE ON issue_stats FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_issue_subscriber_updated_at ON issue_subscriber; +CREATE TRIGGER trg_issue_subscriber_updated_at BEFORE UPDATE ON issue_subscriber FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_issue_template_updated_at ON issue_template; +CREATE TRIGGER trg_issue_template_updated_at BEFORE UPDATE ON issue_template FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_message_updated_at ON message; +CREATE TRIGGER trg_message_updated_at BEFORE UPDATE ON message FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_message_bookmark_updated_at ON message_bookmark; +CREATE TRIGGER trg_message_bookmark_updated_at BEFORE UPDATE ON message_bookmark FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_message_thread_updated_at ON message_thread; +CREATE TRIGGER trg_message_thread_updated_at BEFORE UPDATE ON message_thread FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_notification_updated_at ON notification; +CREATE TRIGGER trg_notification_updated_at BEFORE UPDATE ON notification FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_notification_block_updated_at ON notification_block; +CREATE TRIGGER trg_notification_block_updated_at BEFORE UPDATE ON notification_block FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_notification_delivery_updated_at ON notification_delivery; +CREATE TRIGGER trg_notification_delivery_updated_at BEFORE UPDATE ON notification_delivery FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_notification_subscription_updated_at ON notification_subscription; +CREATE TRIGGER trg_notification_subscription_updated_at BEFORE UPDATE ON notification_subscription FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_notification_template_updated_at ON notification_template; +CREATE TRIGGER trg_notification_template_updated_at BEFORE UPDATE ON notification_template FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_pr_check_run_updated_at ON pr_check_run; +CREATE TRIGGER trg_pr_check_run_updated_at BEFORE UPDATE ON pr_check_run FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_pr_file_updated_at ON pr_file; +CREATE TRIGGER trg_pr_file_updated_at BEFORE UPDATE ON pr_file FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_pr_label_updated_at ON pr_label; +CREATE TRIGGER trg_pr_label_updated_at BEFORE UPDATE ON pr_label FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_pr_merge_strategy_updated_at ON pr_merge_strategy; +CREATE TRIGGER trg_pr_merge_strategy_updated_at BEFORE UPDATE ON pr_merge_strategy FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_pr_status_updated_at ON pr_status; +CREATE TRIGGER trg_pr_status_updated_at BEFORE UPDATE ON pr_status FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_pr_subscription_updated_at ON pr_subscription; +CREATE TRIGGER trg_pr_subscription_updated_at BEFORE UPDATE ON pr_subscription FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_pull_request_updated_at ON pull_request; +CREATE TRIGGER trg_pull_request_updated_at BEFORE UPDATE ON pull_request FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_repo_updated_at ON repo; +CREATE TRIGGER trg_repo_updated_at BEFORE UPDATE ON repo FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_repo_branch_updated_at ON repo_branch; +CREATE TRIGGER trg_repo_branch_updated_at BEFORE UPDATE ON repo_branch FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_repo_commit_comment_updated_at ON repo_commit_comment; +CREATE TRIGGER trg_repo_commit_comment_updated_at BEFORE UPDATE ON repo_commit_comment FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_repo_commit_status_updated_at ON repo_commit_status; +CREATE TRIGGER trg_repo_commit_status_updated_at BEFORE UPDATE ON repo_commit_status FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_repo_deploy_key_updated_at ON repo_deploy_key; +CREATE TRIGGER trg_repo_deploy_key_updated_at BEFORE UPDATE ON repo_deploy_key FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_repo_member_updated_at ON repo_member; +CREATE TRIGGER trg_repo_member_updated_at BEFORE UPDATE ON repo_member FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_repo_push_lock_updated_at ON repo_push_lock; +CREATE TRIGGER trg_repo_push_lock_updated_at BEFORE UPDATE ON repo_push_lock FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_repo_release_updated_at ON repo_release; +CREATE TRIGGER trg_repo_release_updated_at BEFORE UPDATE ON repo_release FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_repo_stats_updated_at ON repo_stats; +CREATE TRIGGER trg_repo_stats_updated_at BEFORE UPDATE ON repo_stats FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_repo_watch_updated_at ON repo_watch; +CREATE TRIGGER trg_repo_watch_updated_at BEFORE UPDATE ON repo_watch FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_repo_webhook_updated_at ON repo_webhook; +CREATE TRIGGER trg_repo_webhook_updated_at BEFORE UPDATE ON repo_webhook FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_user_updated_at ON "user"; +CREATE TRIGGER trg_user_updated_at BEFORE UPDATE ON "user" FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_user_appearance_updated_at ON user_appearance; +CREATE TRIGGER trg_user_appearance_updated_at BEFORE UPDATE ON user_appearance FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_user_device_updated_at ON user_device; +CREATE TRIGGER trg_user_device_updated_at BEFORE UPDATE ON user_device FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_user_gpg_key_updated_at ON user_gpg_key; +CREATE TRIGGER trg_user_gpg_key_updated_at BEFORE UPDATE ON user_gpg_key FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_user_mail_updated_at ON user_mail; +CREATE TRIGGER trg_user_mail_updated_at BEFORE UPDATE ON user_mail FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_user_notify_setting_updated_at ON user_notify_setting; +CREATE TRIGGER trg_user_notify_setting_updated_at BEFORE UPDATE ON user_notify_setting FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_user_password_updated_at ON user_password; +CREATE TRIGGER trg_user_password_updated_at BEFORE UPDATE ON user_password FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_user_personal_access_token_updated_at ON user_personal_access_token; +CREATE TRIGGER trg_user_personal_access_token_updated_at BEFORE UPDATE ON user_personal_access_token FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_user_profile_updated_at ON user_profile; +CREATE TRIGGER trg_user_profile_updated_at BEFORE UPDATE ON user_profile FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_user_ssh_key_updated_at ON user_ssh_key; +CREATE TRIGGER trg_user_ssh_key_updated_at BEFORE UPDATE ON user_ssh_key FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_workspace_updated_at ON workspace; +CREATE TRIGGER trg_workspace_updated_at BEFORE UPDATE ON workspace FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_workspace_billing_updated_at ON workspace_billing; +CREATE TRIGGER trg_workspace_billing_updated_at BEFORE UPDATE ON workspace_billing FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_workspace_custom_branding_updated_at ON workspace_custom_branding; +CREATE TRIGGER trg_workspace_custom_branding_updated_at BEFORE UPDATE ON workspace_custom_branding FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_workspace_domain_updated_at ON workspace_domain; +CREATE TRIGGER trg_workspace_domain_updated_at BEFORE UPDATE ON workspace_domain FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_workspace_integration_updated_at ON workspace_integration; +CREATE TRIGGER trg_workspace_integration_updated_at BEFORE UPDATE ON workspace_integration FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_workspace_member_updated_at ON workspace_member; +CREATE TRIGGER trg_workspace_member_updated_at BEFORE UPDATE ON workspace_member FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_workspace_pending_approval_updated_at ON workspace_pending_approval; +CREATE TRIGGER trg_workspace_pending_approval_updated_at BEFORE UPDATE ON workspace_pending_approval FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_workspace_settings_updated_at ON workspace_settings; +CREATE TRIGGER trg_workspace_settings_updated_at BEFORE UPDATE ON workspace_settings FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_workspace_stats_updated_at ON workspace_stats; +CREATE TRIGGER trg_workspace_stats_updated_at BEFORE UPDATE ON workspace_stats FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_workspace_webhook_updated_at ON workspace_webhook; +CREATE TRIGGER trg_workspace_webhook_updated_at BEFORE UPDATE ON workspace_webhook FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- ============================================================ +-- 2. set_deleted_at — auto-timestamp soft delete +-- Fires when deleted_at transitions NULL → NOT NULL. +-- Works for ANY table with a deleted_at column (no status dependency). +-- ============================================================ +CREATE OR REPLACE FUNCTION set_deleted_at() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN + NEW.deleted_at = NOW(); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Only tables that contain BOTH status AND deleted_at columns +-- Verified from models: user, repo, workspace, issue, pull_request, channel, +-- conversation, conversation_message, agent, message, issue_comment, +-- repo_commit_comment, repo_release, notification, ai_model, conversation_attachment + +DROP TRIGGER IF EXISTS trg_user_deleted_at ON "user"; +CREATE TRIGGER trg_user_deleted_at BEFORE UPDATE ON "user" FOR EACH ROW EXECUTE FUNCTION set_deleted_at(); + +DROP TRIGGER IF EXISTS trg_repo_deleted_at ON repo; +CREATE TRIGGER trg_repo_deleted_at BEFORE UPDATE ON repo FOR EACH ROW EXECUTE FUNCTION set_deleted_at(); + +DROP TRIGGER IF EXISTS trg_workspace_deleted_at ON workspace; +CREATE TRIGGER trg_workspace_deleted_at BEFORE UPDATE ON workspace FOR EACH ROW EXECUTE FUNCTION set_deleted_at(); + +DROP TRIGGER IF EXISTS trg_issue_deleted_at ON issue; +CREATE TRIGGER trg_issue_deleted_at BEFORE UPDATE ON issue FOR EACH ROW EXECUTE FUNCTION set_deleted_at(); + +DROP TRIGGER IF EXISTS trg_pull_request_deleted_at ON pull_request; +CREATE TRIGGER trg_pull_request_deleted_at BEFORE UPDATE ON pull_request FOR EACH ROW EXECUTE FUNCTION set_deleted_at(); + +DROP TRIGGER IF EXISTS trg_channel_deleted_at ON channel; +CREATE TRIGGER trg_channel_deleted_at BEFORE UPDATE ON channel FOR EACH ROW EXECUTE FUNCTION set_deleted_at(); + +DROP TRIGGER IF EXISTS trg_conversation_deleted_at ON conversation; +CREATE TRIGGER trg_conversation_deleted_at BEFORE UPDATE ON conversation FOR EACH ROW EXECUTE FUNCTION set_deleted_at(); + +DROP TRIGGER IF EXISTS trg_conversation_message_deleted_at ON conversation_message; +CREATE TRIGGER trg_conversation_message_deleted_at BEFORE UPDATE ON conversation_message FOR EACH ROW EXECUTE FUNCTION set_deleted_at(); + +DROP TRIGGER IF EXISTS trg_agent_deleted_at ON agent; +CREATE TRIGGER trg_agent_deleted_at BEFORE UPDATE ON agent FOR EACH ROW EXECUTE FUNCTION set_deleted_at(); + +DROP TRIGGER IF EXISTS trg_message_deleted_at ON message; +CREATE TRIGGER trg_message_deleted_at BEFORE UPDATE ON message FOR EACH ROW EXECUTE FUNCTION set_deleted_at(); + +DROP TRIGGER IF EXISTS trg_issue_comment_deleted_at ON issue_comment; +CREATE TRIGGER trg_issue_comment_deleted_at BEFORE UPDATE ON issue_comment FOR EACH ROW EXECUTE FUNCTION set_deleted_at(); + +DROP TRIGGER IF EXISTS trg_repo_commit_comment_deleted_at ON repo_commit_comment; +CREATE TRIGGER trg_repo_commit_comment_deleted_at BEFORE UPDATE ON repo_commit_comment FOR EACH ROW EXECUTE FUNCTION set_deleted_at(); + +DROP TRIGGER IF EXISTS trg_repo_release_deleted_at ON repo_release; +CREATE TRIGGER trg_repo_release_deleted_at BEFORE UPDATE ON repo_release FOR EACH ROW EXECUTE FUNCTION set_deleted_at(); + +DROP TRIGGER IF EXISTS trg_notification_deleted_at ON notification; +CREATE TRIGGER trg_notification_deleted_at BEFORE UPDATE ON notification FOR EACH ROW EXECUTE FUNCTION set_deleted_at(); + +DROP TRIGGER IF EXISTS trg_ai_model_deleted_at ON ai_model; +CREATE TRIGGER trg_ai_model_deleted_at BEFORE UPDATE ON ai_model FOR EACH ROW EXECUTE FUNCTION set_deleted_at(); + +DROP TRIGGER IF EXISTS trg_conversation_attachment_deleted_at ON conversation_attachment; +CREATE TRIGGER trg_conversation_attachment_deleted_at BEFORE UPDATE ON conversation_attachment FOR EACH ROW EXECUTE FUNCTION set_deleted_at(); + +-- ============================================================ +-- 3. Helper: resolve current user from session variable +-- Application must SET LOCAL app.current_user_id = '...' before writes. +-- ============================================================ +CREATE OR REPLACE FUNCTION app_current_user_id() +RETURNS UUID AS $$ +BEGIN + RETURN current_setting('app.current_user_id', true)::UUID; +EXCEPTION + WHEN OTHERS THEN + RETURN NULL; +END; +$$ LANGUAGE plpgsql STABLE; + +-- ============================================================ +-- 4. Event recording — issue / pull_request / channel +-- Tracks: created, renamed, state_changed, priority_changed, +-- draft_toggled, archived, restored. +-- ============================================================ + +-- 4a. issue events +CREATE OR REPLACE FUNCTION record_issue_event() +RETURNS TRIGGER AS $$ +DECLARE + actor UUID := app_current_user_id(); +BEGIN + IF TG_OP = 'INSERT' THEN + INSERT INTO issue_event (issue_id, actor_id, event_type, created_at) + VALUES (NEW.id, COALESCE(actor, NEW.author_id), 'created', NOW()); + RETURN NEW; + END IF; + + IF NEW.title IS DISTINCT FROM OLD.title THEN + INSERT INTO issue_event (issue_id, actor_id, event_type, old_value, new_value, created_at) + VALUES (NEW.id, actor, 'renamed', OLD.title, NEW.title, NOW()); + END IF; + + IF NEW.state IS DISTINCT FROM OLD.state THEN + INSERT INTO issue_event (issue_id, actor_id, event_type, old_value, new_value, created_at) + VALUES (NEW.id, actor, 'state_changed', OLD.state, NEW.state, NOW()); + END IF; + + IF NEW.priority IS DISTINCT FROM OLD.priority THEN + INSERT INTO issue_event (issue_id, actor_id, event_type, old_value, new_value, created_at) + VALUES (NEW.id, actor, 'priority_changed', OLD.priority, NEW.priority, NOW()); + END IF; + + IF NEW.body IS DISTINCT FROM OLD.body THEN + INSERT INTO issue_event (issue_id, actor_id, event_type, created_at) + VALUES (NEW.id, actor, 'body_updated', NOW()); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_issue_event ON issue; +CREATE TRIGGER trg_issue_event + AFTER INSERT OR UPDATE ON issue + FOR EACH ROW EXECUTE FUNCTION record_issue_event(); + +-- 4b. pull_request events +CREATE OR REPLACE FUNCTION record_pr_event() +RETURNS TRIGGER AS $$ +DECLARE + actor UUID := app_current_user_id(); +BEGIN + IF TG_OP = 'INSERT' THEN + INSERT INTO pr_event (pull_request_id, actor_id, event_type, created_at) + VALUES (NEW.id, COALESCE(actor, NEW.author_id), 'created', NOW()); + RETURN NEW; + END IF; + + IF NEW.title IS DISTINCT FROM OLD.title THEN + INSERT INTO pr_event (pull_request_id, actor_id, event_type, old_value, new_value, created_at) + VALUES (NEW.id, actor, 'renamed', OLD.title, NEW.title, NOW()); + END IF; + + IF NEW.state IS DISTINCT FROM OLD.state THEN + INSERT INTO pr_event (pull_request_id, actor_id, event_type, old_value, new_value, created_at) + VALUES (NEW.id, actor, 'state_changed', OLD.state, NEW.state, NOW()); + END IF; + + IF NEW.draft IS DISTINCT FROM OLD.draft THEN + INSERT INTO pr_event (pull_request_id, actor_id, event_type, old_value, new_value, created_at) + VALUES (NEW.id, actor, 'draft_toggled', OLD.draft::text, NEW.draft::text, NOW()); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_pr_event ON pull_request; +CREATE TRIGGER trg_pr_event + AFTER INSERT OR UPDATE ON pull_request + FOR EACH ROW EXECUTE FUNCTION record_pr_event(); + +-- 4c. channel events +CREATE OR REPLACE FUNCTION record_channel_event() +RETURNS TRIGGER AS $$ +DECLARE + actor UUID := app_current_user_id(); +BEGIN + IF TG_OP = 'INSERT' THEN + INSERT INTO channel_event (channel_id, actor_id, event_type, created_at) + VALUES (NEW.id, COALESCE(actor, NEW.created_by), 'created', NOW()); + RETURN NEW; + END IF; + + IF NEW.name IS DISTINCT FROM OLD.name THEN + INSERT INTO channel_event (channel_id, actor_id, event_type, old_value, new_value, created_at) + VALUES (NEW.id, actor, 'renamed', OLD.name, NEW.name, NOW()); + END IF; + + IF NEW.archived IS DISTINCT FROM OLD.archived THEN + INSERT INTO channel_event (channel_id, actor_id, event_type, old_value, new_value, created_at) + VALUES (NEW.id, actor, 'archive_toggled', OLD.archived::text, NEW.archived::text, NOW()); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_channel_event ON channel; +CREATE TRIGGER trg_channel_event + AFTER INSERT OR UPDATE ON channel + FOR EACH ROW EXECUTE FUNCTION record_channel_event(); + +-- ============================================================ +-- 5. Workspace audit log +-- Only applied to tables that directly own a workspace_id column. +-- actor_id resolved from app.current_user_id session variable. +-- ============================================================ + +-- Tables with workspace_id (verified from models): +-- repo, channel, channel_invitation, channel_slash_command, +-- agent, agent_event_subscription, agent_execution, agent_schedule, +-- agent_workspace_binding, conversation, +-- notification, notification_block, notification_subscription, +-- workspace_billing, workspace_custom_branding, workspace_domain, +-- workspace_integration, workspace_invitation, workspace_member, +-- workspace_pending_approval, workspace_settings, workspace_webhook + +CREATE OR REPLACE FUNCTION record_workspace_audit() +RETURNS TRIGGER AS $$ +DECLARE + ws_id UUID; + actor UUID := app_current_user_id(); + action_text TEXT; +BEGIN + ws_id := COALESCE(NEW.workspace_id, OLD.workspace_id); + + IF ws_id IS NULL THEN + RETURN COALESCE(NEW, OLD); + END IF; + + action_text := CASE TG_OP + WHEN 'INSERT' THEN 'created' + WHEN 'UPDATE' THEN 'updated' + WHEN 'DELETE' THEN 'deleted' + END; + + INSERT INTO workspace_audit_log (workspace_id, actor_id, action, target_type, target_id, created_at) + VALUES (ws_id, actor, action_text, TG_TABLE_NAME, COALESCE(NEW.id, OLD.id), NOW()); + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +-- Apply to tables with workspace_id column +DROP TRIGGER IF EXISTS trg_repo_audit ON repo; +CREATE TRIGGER trg_repo_audit AFTER INSERT OR UPDATE OR DELETE ON repo FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + + + +DROP TRIGGER IF EXISTS trg_channel_audit ON channel; +CREATE TRIGGER trg_channel_audit AFTER INSERT OR UPDATE OR DELETE ON channel FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + + +DROP TRIGGER IF EXISTS trg_channel_slash_command_audit ON channel_slash_command; +CREATE TRIGGER trg_channel_slash_command_audit AFTER INSERT OR UPDATE OR DELETE ON channel_slash_command FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_agent_audit ON agent; +CREATE TRIGGER trg_agent_audit AFTER INSERT OR UPDATE OR DELETE ON agent FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_agent_schedule_audit ON agent_schedule; +CREATE TRIGGER trg_agent_schedule_audit AFTER INSERT OR UPDATE OR DELETE ON agent_schedule FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_agent_workspace_binding_audit ON agent_workspace_binding; +CREATE TRIGGER trg_agent_workspace_binding_audit AFTER INSERT OR UPDATE OR DELETE ON agent_workspace_binding FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_conversation_audit ON conversation; +CREATE TRIGGER trg_conversation_audit AFTER INSERT OR UPDATE OR DELETE ON conversation FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_channel_invitation_audit ON channel_invitation; +CREATE TRIGGER trg_channel_invitation_audit AFTER INSERT OR UPDATE OR DELETE ON channel_invitation FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_agent_event_subscription_audit ON agent_event_subscription; +CREATE TRIGGER trg_agent_event_subscription_audit AFTER INSERT OR UPDATE OR DELETE ON agent_event_subscription FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_agent_execution_audit ON agent_execution; +CREATE TRIGGER trg_agent_execution_audit AFTER INSERT OR UPDATE OR DELETE ON agent_execution FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_notification_audit ON notification; +CREATE TRIGGER trg_notification_audit AFTER INSERT OR UPDATE OR DELETE ON notification FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_notification_block_audit ON notification_block; +CREATE TRIGGER trg_notification_block_audit AFTER INSERT OR UPDATE OR DELETE ON notification_block FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_notification_subscription_audit ON notification_subscription; +CREATE TRIGGER trg_notification_subscription_audit AFTER INSERT OR UPDATE OR DELETE ON notification_subscription FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_workspace_billing_audit ON workspace_billing; +CREATE TRIGGER trg_workspace_billing_audit AFTER INSERT OR UPDATE OR DELETE ON workspace_billing FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_workspace_custom_branding_audit ON workspace_custom_branding; +CREATE TRIGGER trg_workspace_custom_branding_audit AFTER INSERT OR UPDATE OR DELETE ON workspace_custom_branding FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_workspace_domain_audit ON workspace_domain; +CREATE TRIGGER trg_workspace_domain_audit AFTER INSERT OR UPDATE OR DELETE ON workspace_domain FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_workspace_integration_audit ON workspace_integration; +CREATE TRIGGER trg_workspace_integration_audit AFTER INSERT OR UPDATE OR DELETE ON workspace_integration FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_workspace_invitation_audit ON workspace_invitation; +CREATE TRIGGER trg_workspace_invitation_audit AFTER INSERT OR UPDATE OR DELETE ON workspace_invitation FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_workspace_member_audit ON workspace_member; +CREATE TRIGGER trg_workspace_member_audit AFTER INSERT OR UPDATE OR DELETE ON workspace_member FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_workspace_pending_approval_audit ON workspace_pending_approval; +CREATE TRIGGER trg_workspace_pending_approval_audit AFTER INSERT OR UPDATE OR DELETE ON workspace_pending_approval FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_workspace_settings_audit ON workspace_settings; +CREATE TRIGGER trg_workspace_settings_audit AFTER INSERT OR UPDATE OR DELETE ON workspace_settings FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +DROP TRIGGER IF EXISTS trg_workspace_webhook_audit ON workspace_webhook; +CREATE TRIGGER trg_workspace_webhook_audit AFTER INSERT OR UPDATE OR DELETE ON workspace_webhook FOR EACH ROW EXECUTE FUNCTION record_workspace_audit(); + +-- ============================================================ +-- 6. User security log +-- Password changes, logins, session revocations. +-- ============================================================ +CREATE OR REPLACE FUNCTION record_user_security_event() +RETURNS TRIGGER AS $$ +DECLARE + actor UUID := app_current_user_id(); +BEGIN + IF TG_OP = 'UPDATE' THEN + IF NEW.password_hash IS DISTINCT FROM OLD.password_hash THEN + INSERT INTO user_security_log (user_id, event_type, description, created_at) + VALUES (NEW.user_id, 'password_changed', 'Password updated', NOW()); + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_user_password_security ON user_password; +CREATE TRIGGER trg_user_password_security + AFTER UPDATE ON user_password + FOR EACH ROW EXECUTE FUNCTION record_user_security_event(); + +CREATE OR REPLACE FUNCTION record_user_session_event() +RETURNS TRIGGER AS $$ +DECLARE + actor UUID := app_current_user_id(); +BEGIN + IF TG_OP = 'INSERT' THEN + INSERT INTO user_security_log (user_id, event_type, description, ip_address, user_agent, created_at) + VALUES (NEW.user_id, 'login', 'User logged in', NEW.ip_address, NEW.user_agent, NOW()); + ELSIF TG_OP = 'UPDATE' AND NEW.revoked_at IS NOT NULL AND OLD.revoked_at IS NULL THEN + INSERT INTO user_security_log (user_id, event_type, description, created_at) + VALUES (NEW.user_id, 'session_revoked', 'Session revoked', NOW()); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_user_session_event ON user_session; +CREATE TRIGGER trg_user_session_event + AFTER INSERT OR UPDATE ON user_session + FOR EACH ROW EXECUTE FUNCTION record_user_session_event(); + +-- ============================================================ +-- 7. Stats auto-maintenance +-- issue_stats.comments_count — incremented on comment insert/delete. +-- channel_stats.messages_count — incremented on message insert. +-- Stats rows MUST be pre-created (no auto-INSERT with zero counts). +-- ============================================================ + +-- 7a. Issue comment stats (UPDATE only, no INSERT) +CREATE OR REPLACE FUNCTION update_issue_stats() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + UPDATE issue_stats + SET comments_count = comments_count + 1, + updated_at = NOW() + WHERE issue_id = NEW.issue_id; + + IF NOT FOUND THEN + RAISE WARNING 'issue_stats row missing for issue_id %. Insert skipped; seed stats row first.', NEW.issue_id; + END IF; + + ELSIF TG_OP = 'DELETE' THEN + UPDATE issue_stats + SET comments_count = GREATEST(comments_count - 1, 0), + updated_at = NOW() + WHERE issue_id = OLD.issue_id; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_issue_comment_stats ON issue_comment; +CREATE TRIGGER trg_issue_comment_stats + AFTER INSERT OR DELETE ON issue_comment + FOR EACH ROW EXECUTE FUNCTION update_issue_stats(); + +-- 7b. Channel message stats (UPDATE only, no INSERT) +CREATE OR REPLACE FUNCTION update_channel_stats() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + UPDATE channel_stats + SET messages_count = messages_count + 1, + last_activity_at = NOW(), + updated_at = NOW() + WHERE channel_id = NEW.channel_id; + + IF NOT FOUND THEN + RAISE WARNING 'channel_stats row missing for channel_id %. Insert skipped; seed stats row first.', NEW.channel_id; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_channel_message_stats ON message; +CREATE TRIGGER trg_channel_message_stats + AFTER INSERT ON message + FOR EACH ROW EXECUTE FUNCTION update_channel_stats(); diff --git a/migrate/003_user_2fa.sql b/migrate/003_user_2fa.sql new file mode 100644 index 0000000..62827b2 --- /dev/null +++ b/migrate/003_user_2fa.sql @@ -0,0 +1,18 @@ +-- ============================================================ +-- Migration: 003_user_2fa.sql +-- Table: user_2fa — TOTP two-factor authentication +-- ============================================================ + +BEGIN; + +CREATE TABLE IF NOT EXISTS user_2fa ( + user_id UUID PRIMARY KEY REFERENCES "user"(id) ON DELETE CASCADE, + secret TEXT NULL, + backup_codes TEXT NOT NULL, + enabled BOOLEAN NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + +); + +COMMIT; diff --git a/migrate/004_auth_constraints.sql b/migrate/004_auth_constraints.sql new file mode 100644 index 0000000..86183f7 --- /dev/null +++ b/migrate/004_auth_constraints.sql @@ -0,0 +1,8 @@ +-- Auth identity uniqueness and lookup hardening. +CREATE UNIQUE INDEX IF NOT EXISTS uq_user_username_active_ci + ON "user" (lower(username)) + WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_user_mail_verified_email_ci + ON user_mail (lower(email)) + WHERE is_verified = true; diff --git a/migrate/005_issue_workspace_upgrade.sql b/migrate/005_issue_workspace_upgrade.sql new file mode 100644 index 0000000..7bccd72 --- /dev/null +++ b/migrate/005_issue_workspace_upgrade.sql @@ -0,0 +1,124 @@ +-- 005: Issue workspace-level upgrade + slug removal +-- 1. Drop slug from workspace +-- 2. Lift issue from repo-level to workspace-level (repo_id -> workspace_id) +-- 3. Add issue_repo_relation for multi-repo association +-- 4. Add milestone_id to issue +-- 5. Add missing unique constraints + +ALTER TABLE workspace DROP COLUMN IF EXISTS slug; +CREATE UNIQUE INDEX IF NOT EXISTS uq_workspace_name ON workspace (lower(name)) WHERE deleted_at IS NULL; + +DROP INDEX IF EXISTS idx_issue_repo_id; +DROP INDEX IF EXISTS idx_issue_repo_created; + +ALTER TABLE issue DROP CONSTRAINT IF EXISTS issue_repo_id_fkey; + +DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'issue' + AND column_name = 'repo_id' + ) + AND NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'issue' + AND column_name = 'workspace_id' + ) THEN + ALTER TABLE issue RENAME COLUMN repo_id TO workspace_id; + END IF; + END $$; + +DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'issue_workspace_id_fkey' + AND conrelid = 'issue'::regclass + ) THEN + ALTER TABLE issue + ADD CONSTRAINT issue_workspace_id_fkey + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE; + END IF; + END $$; + +CREATE INDEX IF NOT EXISTS idx_issue_workspace_id ON issue (workspace_id); +CREATE INDEX IF NOT EXISTS idx_issue_ws_created ON issue (workspace_id, created_at DESC); + +DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'uq_issue_workspace_number' + AND conrelid = 'issue'::regclass + ) THEN + ALTER TABLE issue + ADD CONSTRAINT uq_issue_workspace_number UNIQUE (workspace_id, number); + END IF; + END $$; + +CREATE TABLE IF NOT EXISTS issue_repo_relation ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issue_id UUID NOT NULL REFERENCES issue(id) ON DELETE CASCADE, + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + relation_type TEXT NOT NULL, + created_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_issue_repo_relation UNIQUE (issue_id, repo_id) +); + +CREATE INDEX IF NOT EXISTS idx_issue_repo_relation_issue_id + ON issue_repo_relation (issue_id); + +CREATE INDEX IF NOT EXISTS idx_issue_repo_relation_repo_id + ON issue_repo_relation (repo_id); + +ALTER TABLE issue + ADD COLUMN IF NOT EXISTS milestone_id UUID NULL REFERENCES issue_milestone(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_issue_milestone_id ON issue (milestone_id); + +DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'uq_issue_label_relation' + AND conrelid = 'issue_label_relation'::regclass + ) THEN + ALTER TABLE issue_label_relation + ADD CONSTRAINT uq_issue_label_relation UNIQUE (issue_id, label_id); + END IF; + END $$; +CREATE UNIQUE INDEX IF NOT EXISTS uq_repo_workspace_name + ON repo (workspace_id, lower(name)) WHERE deleted_at IS NULL; + +-- ─── 7. pull_request: unique number per repo ─────────────────────────── +DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'uq_pull_request_repo_number' AND conrelid = 'pull_request'::regclass + ) THEN + ALTER TABLE pull_request + ADD CONSTRAINT uq_pull_request_repo_number UNIQUE (repo_id, number); + END IF; + END $$; + +-- ─── 8. pr_label_relation: unique constraint ──────────────────────────── +DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'uq_pr_label_relation' AND conrelid = 'pr_label_relation'::regclass + ) THEN + ALTER TABLE pr_label_relation + ADD CONSTRAINT uq_pr_label_relation UNIQUE (pull_request_id, label_id); + END IF; + END $$; diff --git a/migrate/006_pr_review_fork_protection.sql b/migrate/006_pr_review_fork_protection.sql new file mode 100644 index 0000000..c9201bc --- /dev/null +++ b/migrate/006_pr_review_fork_protection.sql @@ -0,0 +1,76 @@ +-- 006: PR Reviews, Branch Protection, Fork enhancements + +CREATE TABLE IF NOT EXISTS pr_review ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pull_request_id UUID NOT NULL REFERENCES pull_request(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + state TEXT NOT NULL, + body TEXT NULL, + commit_sha TEXT NULL, + submitted_at TIMESTAMPTZ NULL, + dismissed_at TIMESTAMPTZ NULL, + dismissed_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + dismiss_reason TEXT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_pr_review_pull_request_id ON pr_review (pull_request_id); +CREATE INDEX IF NOT EXISTS idx_pr_review_author_id ON pr_review (author_id); + +CREATE TABLE IF NOT EXISTS pr_review_comment ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + review_id UUID NOT NULL REFERENCES pr_review(id) ON DELETE CASCADE, + pull_request_id UUID NOT NULL REFERENCES pull_request(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + body TEXT NOT NULL, + path TEXT NOT NULL, + line INTEGER NULL, + original_line INTEGER NULL, + start_line INTEGER NULL, + original_start_line INTEGER NULL, + diff_hunk TEXT NULL, + in_reply_to_id UUID NULL REFERENCES pr_review_comment(id) ON DELETE CASCADE, + edited_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_pr_review_comment_review_id ON pr_review_comment (review_id); +CREATE INDEX IF NOT EXISTS idx_pr_review_comment_pull_request_id ON pr_review_comment (pull_request_id); +CREATE INDEX IF NOT EXISTS idx_pr_review_comment_author_id ON pr_review_comment (author_id); +CREATE INDEX IF NOT EXISTS idx_pr_review_comment_path ON pr_review_comment (path); + +CREATE TABLE IF NOT EXISTS branch_protection_rule ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + pattern TEXT NOT NULL, + require_approvals INTEGER NOT NULL DEFAULT 0, + require_status_checks BOOLEAN NOT NULL DEFAULT false, + required_status_checks TEXT[] NOT NULL DEFAULT '{}', + require_linear_history BOOLEAN NOT NULL DEFAULT false, + allow_force_pushes BOOLEAN NOT NULL DEFAULT false, + allow_deletions BOOLEAN NOT NULL DEFAULT false, + require_signed_commits BOOLEAN NOT NULL DEFAULT false, + require_code_owner_review BOOLEAN NOT NULL DEFAULT false, + dismiss_stale_reviews BOOLEAN NOT NULL DEFAULT false, + restrict_pushes BOOLEAN NOT NULL DEFAULT false, + push_allowances UUID[] NOT NULL DEFAULT '{}', + restrict_review_dismissal BOOLEAN NOT NULL DEFAULT false, + dismissal_allowances UUID[] NOT NULL DEFAULT '{}', + require_conversation_resolution BOOLEAN NOT NULL DEFAULT false, + created_by UUID NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_branch_protection_rule UNIQUE (repo_id, pattern) +); +CREATE INDEX IF NOT EXISTS idx_branch_protection_rule_repo_id ON branch_protection_rule (repo_id); + +CREATE TABLE IF NOT EXISTS repo_fork ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + parent_repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + fork_repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + forked_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_repo_fork UNIQUE (parent_repo_id, fork_repo_id) +); +CREATE INDEX IF NOT EXISTS idx_repo_fork_parent ON repo_fork (parent_repo_id); +CREATE INDEX IF NOT EXISTS idx_repo_fork_fork ON repo_fork (fork_repo_id); diff --git a/migrate/007_issue_reaction.sql b/migrate/007_issue_reaction.sql new file mode 100644 index 0000000..d009a6b --- /dev/null +++ b/migrate/007_issue_reaction.sql @@ -0,0 +1,16 @@ +-- 007: Issue reactions + +-- ─── Issue Reactions ────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS issue_reaction ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issue_id UUID NOT NULL REFERENCES issue(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + content TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id UUID NULL, + created_at TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_issue_reaction UNIQUE (issue_id, user_id, content, target_type) +); +CREATE INDEX IF NOT EXISTS idx_issue_reaction_issue_id ON issue_reaction (issue_id); +CREATE INDEX IF NOT EXISTS idx_issue_reaction_user_id ON issue_reaction (user_id); +CREATE INDEX IF NOT EXISTS idx_issue_reaction_target ON issue_reaction (target_type, target_id); diff --git a/migrate/008_wiki.sql b/migrate/008_wiki.sql new file mode 100644 index 0000000..a8fb226 --- /dev/null +++ b/migrate/008_wiki.sql @@ -0,0 +1,34 @@ +-- 008: Wiki System + +CREATE TABLE IF NOT EXISTS wiki_page ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repo(id) ON DELETE CASCADE, + slug TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + author_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + last_editor_id UUID REFERENCES "user"(id) ON DELETE SET NULL, + version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_wiki_page_repo_slug UNIQUE (repo_id, slug) +); + +CREATE TABLE IF NOT EXISTS wiki_page_revision ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + page_id UUID NOT NULL REFERENCES wiki_page(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + editor_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + commit_message TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_wiki_revision_page_version UNIQUE (page_id, version) +); + +CREATE INDEX idx_wiki_page_repo_id ON wiki_page(repo_id); +CREATE INDEX idx_wiki_page_slug ON wiki_page(slug); +CREATE INDEX idx_wiki_page_deleted_at ON wiki_page(deleted_at) WHERE deleted_at IS NULL; +CREATE INDEX idx_wiki_revision_page_id ON wiki_page_revision(page_id); +CREATE INDEX idx_wiki_revision_version ON wiki_page_revision(version); diff --git a/migrate/009_im_features.sql b/migrate/009_im_features.sql new file mode 100644 index 0000000..7a3972a --- /dev/null +++ b/migrate/009_im_features.sql @@ -0,0 +1,320 @@ +-- 009: IM Features — Discord/Slack-class messaging support +-- +-- New tables: +-- user_presence, user_activity, +-- channel_category, channel_permission_overwrite, im_integration, +-- message_attachment, message_embed, message_draft, message_pin, +-- message_edit_history, saved_message, thread_read_state, +-- custom_emoji + +-- ============================================================ +-- 1. User Presence +-- ============================================================ + +-- models/users/user_presence.rs → user_presence +CREATE TABLE IF NOT EXISTS user_presence ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + status TEXT NOT NULL, + custom_status_text TEXT NULL, + custom_status_emoji TEXT NULL, + device_type TEXT NULL, + ip_address TEXT NULL, + last_active_at TIMESTAMPTZ NOT NULL, + last_seen_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_user_presence_user_id UNIQUE (user_id) +); +CREATE INDEX IF NOT EXISTS idx_user_presence_status ON user_presence (status); + +-- ============================================================ +-- 2. User Activity (Rich Presence) +-- ============================================================ + +-- models/users/user_activity.rs → user_activity +CREATE TABLE IF NOT EXISTS user_activity ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + activity_type TEXT NOT NULL, + name TEXT NOT NULL, + details TEXT NULL, + state TEXT NULL, + application_id TEXT NULL, + assets JSONB NULL, + party_id TEXT NULL, + party_current_size INTEGER NULL, + party_max_size INTEGER NULL, + large_image_url TEXT NULL, + small_image_url TEXT NULL, + start_at TIMESTAMPTZ NULL, + end_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_user_activity_user_id ON user_activity (user_id); + +-- ============================================================ +-- 3. Channel Categories +-- ============================================================ + +-- models/channels/channel_categories.rs → channel_category +CREATE TABLE IF NOT EXISTS channel_category ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + name TEXT NOT NULL, + position INTEGER NOT NULL, + collapsed BOOLEAN NOT NULL, + created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_channel_category_workspace_id ON channel_category (workspace_id); + +-- ============================================================ +-- 4. ALTER channel — add category_id (after channel_category exists) +-- ============================================================ + +ALTER TABLE channel ADD COLUMN IF NOT EXISTS category_id UUID NULL REFERENCES channel_category(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_channel_category_id ON channel (category_id); + +-- ============================================================ +-- 5. Channel Permission Overwrites +-- ============================================================ + +-- models/channels/channel_permission_overwrites.rs → channel_permission_overwrite +CREATE TABLE IF NOT EXISTS channel_permission_overwrite ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + target_type TEXT NOT NULL, + target_id UUID NOT NULL, + allow TEXT[] NOT NULL, + deny TEXT[] NOT NULL, + created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_channel_perm_overwrite UNIQUE (channel_id, target_type, target_id) +); +CREATE INDEX IF NOT EXISTS idx_channel_perm_overwrite_channel_id ON channel_permission_overwrite (channel_id); +CREATE INDEX IF NOT EXISTS idx_channel_perm_overwrite_target ON channel_permission_overwrite (target_type, target_id); + +-- ============================================================ +-- 6. IM Integrations (External Bridge) +-- ============================================================ + +-- models/channels/im_integrations.rs → im_integration +CREATE TABLE IF NOT EXISTS im_integration ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + name TEXT NOT NULL, + external_workspace_id TEXT NULL, + internal_channel_id UUID NULL REFERENCES channel(id) ON DELETE SET NULL, + external_channel_id TEXT NULL, + bot_token_ciphertext TEXT NULL, + webhook_url TEXT NULL, + sync_direction TEXT NOT NULL, + user_mapping JSONB NULL, + enabled BOOLEAN NOT NULL, + installed_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + last_sync_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_im_integration_workspace_id ON im_integration (workspace_id); +CREATE INDEX IF NOT EXISTS idx_im_integration_internal_channel_id ON im_integration (internal_channel_id); +CREATE INDEX IF NOT EXISTS idx_im_integration_provider ON im_integration (provider); + +-- ============================================================ +-- 7. Message Attachments +-- ============================================================ + +-- models/channels/message_attachments.rs → message_attachment +CREATE TABLE IF NOT EXISTS message_attachment ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + url TEXT NOT NULL, + proxy_url TEXT NULL, + size_bytes BIGINT NOT NULL, + mime_type TEXT NOT NULL, + width INTEGER NULL, + height INTEGER NULL, + duration_ms BIGINT NULL, + thumbnail_url TEXT NULL, + blurhash TEXT NULL, + created_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_message_attachment_message_id ON message_attachment (message_id); +CREATE INDEX IF NOT EXISTS idx_message_attachment_channel_id ON message_attachment (channel_id); + +-- ============================================================ +-- 8. Message Embeds (Rich Text / Link Previews) +-- ============================================================ + +-- models/channels/message_embeds.rs → message_embed +CREATE TABLE IF NOT EXISTS message_embed ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE, + embed_type TEXT NOT NULL, + title TEXT NULL, + description TEXT NULL, + url TEXT NULL, + author_name TEXT NULL, + author_url TEXT NULL, + author_icon_url TEXT NULL, + thumbnail_url TEXT NULL, + thumbnail_width INTEGER NULL, + thumbnail_height INTEGER NULL, + image_url TEXT NULL, + image_width INTEGER NULL, + image_height INTEGER NULL, + video_url TEXT NULL, + video_width INTEGER NULL, + video_height INTEGER NULL, + color INTEGER NULL, + fields JSONB NULL, + footer_text TEXT NULL, + footer_icon_url TEXT NULL, + provider_name TEXT NULL, + provider_url TEXT NULL, + "timestamp" TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_message_embed_message_id ON message_embed (message_id); + +-- ============================================================ +-- 9. Message Drafts +-- ============================================================ + +-- models/channels/message_drafts.rs → message_draft +CREATE TABLE IF NOT EXISTS message_draft ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + thread_id UUID NULL, + reply_to_message_id UUID NULL, + content TEXT NOT NULL, + attachments JSONB NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_message_draft_user_channel UNIQUE (user_id, channel_id) +); +CREATE INDEX IF NOT EXISTS idx_message_draft_user_id ON message_draft (user_id); +CREATE INDEX IF NOT EXISTS idx_message_draft_channel_id ON message_draft (channel_id); + +-- ============================================================ +-- 10. Custom Emojis +-- ============================================================ + +-- models/channels/custom_emojis.rs → custom_emoji +CREATE TABLE IF NOT EXISTS custom_emoji ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + name TEXT NOT NULL, + url TEXT NOT NULL, + animated BOOLEAN NOT NULL, + managed BOOLEAN NOT NULL, + created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_custom_emoji_workspace_name UNIQUE (workspace_id, name) +); +CREATE INDEX IF NOT EXISTS idx_custom_emoji_workspace_id ON custom_emoji (workspace_id); + +-- ============================================================ +-- 11. Message Pins +-- ============================================================ + +-- models/channels/message_pins.rs → message_pin +CREATE TABLE IF NOT EXISTS message_pin ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + pinned_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + pinned_at TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_message_pin_message_id UNIQUE (message_id) +); +CREATE INDEX IF NOT EXISTS idx_message_pin_channel_id ON message_pin (channel_id); +CREATE INDEX IF NOT EXISTS idx_message_pin_message_id ON message_pin (message_id); + +-- ============================================================ +-- 12. Message Edit History +-- ============================================================ + +-- models/channels/message_edit_history.rs → message_edit_history +CREATE TABLE IF NOT EXISTS message_edit_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + previous_body TEXT NOT NULL, + edited_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + edited_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_message_edit_history_message_id ON message_edit_history (message_id); +CREATE INDEX IF NOT EXISTS idx_message_edit_history_channel_id ON message_edit_history (channel_id); + +-- ============================================================ +-- 13. Saved Messages +-- ============================================================ + +-- models/channels/saved_messages.rs → saved_message +CREATE TABLE IF NOT EXISTS saved_message ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + note TEXT NULL, + created_at TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_saved_message_user_message UNIQUE (user_id, message_id) +); +CREATE INDEX IF NOT EXISTS idx_saved_message_user_id ON saved_message (user_id); +CREATE INDEX IF NOT EXISTS idx_saved_message_message_id ON saved_message (message_id); + +-- ============================================================ +-- 14. Thread Read States +-- ============================================================ + +-- models/channels/thread_read_states.rs → thread_read_state +CREATE TABLE IF NOT EXISTS thread_read_state ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + thread_id UUID NOT NULL, + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + last_read_message_id UUID NULL, + last_read_at TIMESTAMPTZ NULL, + updated_at TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_thread_read_state_user_thread UNIQUE (user_id, thread_id) +); +CREATE INDEX IF NOT EXISTS idx_thread_read_state_user_id ON thread_read_state (user_id); +CREATE INDEX IF NOT EXISTS idx_thread_read_state_thread_id ON thread_read_state (thread_id); +CREATE INDEX IF NOT EXISTS idx_thread_read_state_channel_id ON thread_read_state (channel_id); + +-- ============================================================ +-- 15. Triggers — auto-refresh updated_at +-- ============================================================ + +DROP TRIGGER IF EXISTS trg_user_presence_updated_at ON user_presence; +CREATE TRIGGER trg_user_presence_updated_at BEFORE UPDATE ON user_presence FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_user_activity_updated_at ON user_activity; +CREATE TRIGGER trg_user_activity_updated_at BEFORE UPDATE ON user_activity FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_channel_category_updated_at ON channel_category; +CREATE TRIGGER trg_channel_category_updated_at BEFORE UPDATE ON channel_category FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_channel_permission_overwrite_updated_at ON channel_permission_overwrite; +CREATE TRIGGER trg_channel_permission_overwrite_updated_at BEFORE UPDATE ON channel_permission_overwrite FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_im_integration_updated_at ON im_integration; +CREATE TRIGGER trg_im_integration_updated_at BEFORE UPDATE ON im_integration FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_message_draft_updated_at ON message_draft; +CREATE TRIGGER trg_message_draft_updated_at BEFORE UPDATE ON message_draft FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_custom_emoji_updated_at ON custom_emoji; +CREATE TRIGGER trg_custom_emoji_updated_at BEFORE UPDATE ON custom_emoji FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_thread_read_state_updated_at ON thread_read_state; +CREATE TRIGGER trg_thread_read_state_updated_at BEFORE UPDATE ON thread_read_state FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/migrate/010_channel_kinds.sql b/migrate/010_channel_kinds.sql new file mode 100644 index 0000000..93c65ac --- /dev/null +++ b/migrate/010_channel_kinds.sql @@ -0,0 +1,187 @@ +-- 010: Channel Kinds — text, voice, stage, forum, announcement +-- +-- ALTER: +-- channel — add channel_kind + voice/forum/stage fields +-- message_thread — add forum post fields (title, tags, pinned, locked) +-- +-- New tables: +-- forum_tag, voice_participant, stage, +-- message_poll, message_poll_option, message_poll_vote + +-- ============================================================ +-- 1. ALTER channel — add channel_kind + voice/forum/stage fields +-- ============================================================ + +ALTER TABLE channel ADD COLUMN IF NOT EXISTS channel_kind TEXT NOT NULL DEFAULT 'text'; +ALTER TABLE channel ADD COLUMN IF NOT EXISTS position INTEGER NULL; +ALTER TABLE channel ADD COLUMN IF NOT EXISTS nsfw BOOLEAN NOT NULL DEFAULT FALSE; + +-- Voice / Stage specific +ALTER TABLE channel ADD COLUMN IF NOT EXISTS bitrate INTEGER NULL; +ALTER TABLE channel ADD COLUMN IF NOT EXISTS user_limit INTEGER NULL; +ALTER TABLE channel ADD COLUMN IF NOT EXISTS rtc_region TEXT NULL; + +-- Forum specific +ALTER TABLE channel ADD COLUMN IF NOT EXISTS default_auto_archive_duration INTEGER NULL; +ALTER TABLE channel ADD COLUMN IF NOT EXISTS default_reaction_emoji TEXT NULL; +ALTER TABLE channel ADD COLUMN IF NOT EXISTS default_sort_order TEXT NULL; +ALTER TABLE channel ADD COLUMN IF NOT EXISTS default_forum_layout TEXT NULL; +ALTER TABLE channel ADD COLUMN IF NOT EXISTS require_tag BOOLEAN NULL; +ALTER TABLE channel ADD COLUMN IF NOT EXISTS available_tags JSONB NULL; +ALTER TABLE channel ADD COLUMN IF NOT EXISTS default_thread_rate_limit INTEGER NULL; + +-- General +ALTER TABLE channel ADD COLUMN IF NOT EXISTS rate_limit_per_user INTEGER NULL; +ALTER TABLE channel ADD COLUMN IF NOT EXISTS parent_channel_id UUID NULL REFERENCES channel(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_channel_channel_kind ON channel (channel_kind); +CREATE INDEX IF NOT EXISTS idx_channel_parent_channel_id ON channel (parent_channel_id); + +-- ============================================================ +-- 2. ALTER message_thread — add forum post fields +-- ============================================================ + +ALTER TABLE message_thread ADD COLUMN IF NOT EXISTS title TEXT NULL; +ALTER TABLE message_thread ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}'; +ALTER TABLE message_thread ADD COLUMN IF NOT EXISTS pinned BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE message_thread ADD COLUMN IF NOT EXISTS locked BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE message_thread ADD COLUMN IF NOT EXISTS rate_limit_per_user INTEGER NULL; +ALTER TABLE message_thread ADD COLUMN IF NOT EXISTS auto_archive_at TIMESTAMPTZ NULL; + +CREATE INDEX IF NOT EXISTS idx_message_thread_pinned ON message_thread (pinned) WHERE pinned; + +-- ============================================================ +-- 3. Forum Tags +-- ============================================================ + +-- models/channels/forum_tags.rs → forum_tag +CREATE TABLE IF NOT EXISTS forum_tag ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + name TEXT NOT NULL, + emoji_id TEXT NULL, + emoji_name TEXT NULL, + moderated BOOLEAN NOT NULL, + position INTEGER NOT NULL, + created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_forum_tag_channel_name UNIQUE (channel_id, name) +); +CREATE INDEX IF NOT EXISTS idx_forum_tag_channel_id ON forum_tag (channel_id); + +-- ============================================================ +-- 4. Voice Participants +-- ============================================================ + +-- models/channels/voice_participants.rs → voice_participant +CREATE TABLE IF NOT EXISTS voice_participant ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + session_id TEXT NULL, + deafened BOOLEAN NOT NULL, + muted BOOLEAN NOT NULL, + self_deafened BOOLEAN NOT NULL, + self_muted BOOLEAN NOT NULL, + self_video BOOLEAN NOT NULL, + streaming BOOLEAN NOT NULL, + speaking BOOLEAN NOT NULL, + joined_at TIMESTAMPTZ NOT NULL, + left_at TIMESTAMPTZ NULL +); +CREATE INDEX IF NOT EXISTS idx_voice_participant_channel_id ON voice_participant (channel_id); +CREATE INDEX IF NOT EXISTS idx_voice_participant_user_id ON voice_participant (user_id); +CREATE INDEX IF NOT EXISTS idx_voice_participant_channel_user ON voice_participant (channel_id, user_id); + +-- ============================================================ +-- 5. Stages +-- ============================================================ + +-- models/channels/stages.rs → stage +CREATE TABLE IF NOT EXISTS stage ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + topic TEXT NOT NULL, + privacy_level TEXT NOT NULL, + discoverable BOOLEAN NOT NULL, + started_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + started_at TIMESTAMPTZ NOT NULL, + ended_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_stage_active_channel UNIQUE (channel_id, ended_at) +); +CREATE INDEX IF NOT EXISTS idx_stage_channel_id ON stage (channel_id); +CREATE INDEX IF NOT EXISTS idx_stage_channel_active ON stage (channel_id) WHERE ended_at IS NULL; + +-- ============================================================ +-- 6. Message Polls +-- ============================================================ + +-- models/channels/message_polls.rs → message_poll +CREATE TABLE IF NOT EXISTS message_poll ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + question TEXT NOT NULL, + description TEXT NULL, + layout TEXT NOT NULL, + allow_multiselect BOOLEAN NOT NULL, + duration_hours INTEGER NULL, + ends_at TIMESTAMPTZ NULL, + total_votes BIGINT NOT NULL, + metadata JSONB NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_message_poll_message_id UNIQUE (message_id) +); +CREATE INDEX IF NOT EXISTS idx_message_poll_channel_id ON message_poll (channel_id); +CREATE INDEX IF NOT EXISTS idx_message_poll_ends_at ON message_poll (ends_at) WHERE ends_at IS NOT NULL; + +-- ============================================================ +-- 7. Message Poll Options +-- ============================================================ + +-- models/channels/message_poll_options.rs → message_poll_option +CREATE TABLE IF NOT EXISTS message_poll_option ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + poll_id UUID NOT NULL REFERENCES message_poll(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + text TEXT NOT NULL, + emoji_id TEXT NULL, + emoji_name TEXT NULL, + vote_count BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_message_poll_option_poll_id ON message_poll_option (poll_id); + +-- ============================================================ +-- 8. Message Poll Votes +-- ============================================================ + +-- models/channels/message_poll_votes.rs → message_poll_vote +CREATE TABLE IF NOT EXISTS message_poll_vote ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + poll_id UUID NOT NULL REFERENCES message_poll(id) ON DELETE CASCADE, + option_id UUID NOT NULL REFERENCES message_poll_option(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + voted_at TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_message_poll_vote_user_option UNIQUE (poll_id, option_id, user_id) +); +CREATE INDEX IF NOT EXISTS idx_message_poll_vote_poll_id ON message_poll_vote (poll_id); +CREATE INDEX IF NOT EXISTS idx_message_poll_vote_user_id ON message_poll_vote (user_id); +CREATE INDEX IF NOT EXISTS idx_message_poll_vote_option_id ON message_poll_vote (option_id); + +-- ============================================================ +-- 9. Triggers — auto-refresh updated_at +-- ============================================================ + +DROP TRIGGER IF EXISTS trg_forum_tag_updated_at ON forum_tag; +CREATE TRIGGER trg_forum_tag_updated_at BEFORE UPDATE ON forum_tag FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_stage_updated_at ON stage; +CREATE TRIGGER trg_stage_updated_at BEFORE UPDATE ON stage FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_message_poll_updated_at ON message_poll; +CREATE TRIGGER trg_message_poll_updated_at BEFORE UPDATE ON message_poll FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/migrate/011_announcement.sql b/migrate/011_announcement.sql new file mode 100644 index 0000000..70448c6 --- /dev/null +++ b/migrate/011_announcement.sql @@ -0,0 +1,145 @@ +-- 011: Announcement Channel — articles, comments, reactions, follows, cross-posts +-- +-- New tables: +-- article, article_comment, article_reaction, +-- channel_follow, article_cross_post + +-- ============================================================ +-- 1. Articles +-- ============================================================ + +-- models/channels/articles.rs → article +CREATE TABLE IF NOT EXISTS article ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + title TEXT NOT NULL, + slug TEXT NOT NULL, + summary TEXT NULL, + body TEXT NOT NULL, + cover_image_url TEXT NULL, + status TEXT NOT NULL, + visibility TEXT NOT NULL, + tags TEXT[] NOT NULL, + published_at TIMESTAMPTZ NULL, + published_by UUID NULL REFERENCES "user"(id) ON DELETE SET NULL, + scheduled_at TIMESTAMPTZ NULL, + unpublished_at TIMESTAMPTZ NULL, + views_count BIGINT NOT NULL DEFAULT 0, + comments_count BIGINT NOT NULL DEFAULT 0, + reactions_count BIGINT NOT NULL DEFAULT 0, + cross_posted BOOLEAN NOT NULL DEFAULT FALSE, + cross_posted_from UUID NULL REFERENCES article(id) ON DELETE SET NULL, + metadata JSONB NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_article_channel_slug UNIQUE (channel_id, slug) +); +CREATE INDEX IF NOT EXISTS idx_article_channel_id ON article (channel_id); +CREATE INDEX IF NOT EXISTS idx_article_author_id ON article (author_id); +CREATE INDEX IF NOT EXISTS idx_article_status ON article (status); +CREATE INDEX IF NOT EXISTS idx_article_published_at ON article (published_at DESC) WHERE published_at IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_article_cross_posted_from ON article (cross_posted_from) WHERE cross_posted_from IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_article_deleted ON article (deleted_at) WHERE deleted_at IS NOT NULL; + +-- ============================================================ +-- 2. Article Comments +-- ============================================================ + +-- models/channels/article_comments.rs → article_comment +CREATE TABLE IF NOT EXISTS article_comment ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + article_id UUID NOT NULL REFERENCES article(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + parent_comment_id UUID NULL REFERENCES article_comment(id) ON DELETE CASCADE, + body TEXT NOT NULL, + edited_at TIMESTAMPTZ NULL, + deleted_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_article_comment_article_id ON article_comment (article_id); +CREATE INDEX IF NOT EXISTS idx_article_comment_author_id ON article_comment (author_id); +CREATE INDEX IF NOT EXISTS idx_article_comment_parent ON article_comment (parent_comment_id) WHERE parent_comment_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_article_comment_deleted ON article_comment (deleted_at) WHERE deleted_at IS NOT NULL; + +-- ============================================================ +-- 3. Article Reactions +-- ============================================================ + +-- models/channels/article_reactions.rs → article_reaction +CREATE TABLE IF NOT EXISTS article_reaction ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + article_id UUID NOT NULL REFERENCES article(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_article_reaction UNIQUE (article_id, user_id, content) +); +CREATE INDEX IF NOT EXISTS idx_article_reaction_article_id ON article_reaction (article_id); +CREATE INDEX IF NOT EXISTS idx_article_reaction_user_id ON article_reaction (user_id); + +-- ============================================================ +-- 4. Channel Follows +-- ============================================================ + +-- models/channels/channel_follows.rs → channel_follow +CREATE TABLE IF NOT EXISTS channel_follow ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_channel_id UUID NOT NULL REFERENCES channel(id) ON DELETE CASCADE, + target_workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + target_channel_id UUID NULL REFERENCES channel(id) ON DELETE SET NULL, + webhook_url TEXT NULL, + webhook_secret_ciphertext TEXT NULL, + enabled BOOLEAN NOT NULL, + followed_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + unfollowed_at TIMESTAMPTZ NULL, + last_delivery_at TIMESTAMPTZ NULL, + last_delivery_status TEXT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_channel_follow_source_target UNIQUE (source_channel_id, target_workspace_id, target_channel_id) +); +CREATE INDEX IF NOT EXISTS idx_channel_follow_source ON channel_follow (source_channel_id); +CREATE INDEX IF NOT EXISTS idx_channel_follow_target_ws ON channel_follow (target_workspace_id); +CREATE INDEX IF NOT EXISTS idx_channel_follow_target_channel ON channel_follow (target_channel_id) WHERE target_channel_id IS NOT NULL; + +-- ============================================================ +-- 5. Article Cross-Posts +-- ============================================================ + +-- models/channels/article_cross_posts.rs → article_cross_post +CREATE TABLE IF NOT EXISTS article_cross_post ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + article_id UUID NOT NULL REFERENCES article(id) ON DELETE CASCADE, + follow_id UUID NOT NULL REFERENCES channel_follow(id) ON DELETE CASCADE, + target_workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + target_channel_id UUID NULL REFERENCES channel(id) ON DELETE SET NULL, + status TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT NULL, + sent_at TIMESTAMPTZ NULL, + delivered_at TIMESTAMPTZ NULL, + failed_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_article_cross_post_article_id ON article_cross_post (article_id); +CREATE INDEX IF NOT EXISTS idx_article_cross_post_follow_id ON article_cross_post (follow_id); +CREATE INDEX IF NOT EXISTS idx_article_cross_post_status ON article_cross_post (status); +CREATE INDEX IF NOT EXISTS idx_article_cross_post_target_ws ON article_cross_post (target_workspace_id); + +-- ============================================================ +-- 6. Triggers — auto-refresh updated_at +-- ============================================================ + +DROP TRIGGER IF EXISTS trg_article_updated_at ON article; +CREATE TRIGGER trg_article_updated_at BEFORE UPDATE ON article FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_article_comment_updated_at ON article_comment; +CREATE TRIGGER trg_article_comment_updated_at BEFORE UPDATE ON article_comment FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_channel_follow_updated_at ON channel_follow; +CREATE TRIGGER trg_channel_follow_updated_at BEFORE UPDATE ON channel_follow FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/migrate/012_im_message_seq.sql b/migrate/012_im_message_seq.sql new file mode 100644 index 0000000..4173ef2 --- /dev/null +++ b/migrate/012_im_message_seq.sql @@ -0,0 +1,13 @@ +ALTER TABLE message ADD COLUMN IF NOT EXISTS seq BIGINT NOT NULL DEFAULT 0; + +WITH ranked AS ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY channel_id ORDER BY created_at ASC, id ASC) AS rn + FROM message + WHERE seq = 0 +) +UPDATE message m +SET seq = ranked.rn +FROM ranked +WHERE m.id = ranked.id; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_message_channel_seq ON message (channel_id, seq); diff --git a/models/agents/agent.rs b/models/agents/agent.rs new file mode 100644 index 0000000..df5ca7c --- /dev/null +++ b/models/agents/agent.rs @@ -0,0 +1,28 @@ +use crate::models::common::{AgentType, Status, Visibility}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct Agent { + pub id: Uuid, + pub owner_id: Uuid, + pub workspace_id: Option, + pub name: String, + pub slug: String, + pub description: Option, + pub avatar_url: Option, + pub agent_type: AgentType, + pub status: Status, + pub visibility: Visibility, + pub default_model_id: Option, + pub current_version_id: Option, + pub system_prompt: Option, + pub tools: Vec, + pub tags: Vec, + pub enabled: bool, + pub last_run_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/models/agents/agent_event_subscriptions.rs b/models/agents/agent_event_subscriptions.rs new file mode 100644 index 0000000..1a08776 --- /dev/null +++ b/models/agents/agent_event_subscriptions.rs @@ -0,0 +1,19 @@ +use crate::models::common::EventType; +use crate::models::json_types::{AgentEventFilters, TypedJson}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct AgentEventSubscription { + pub id: Uuid, + pub agent_id: Uuid, + pub workspace_id: Option, + pub repo_id: Option, + pub event_type: EventType, + pub filters: Option>, + pub enabled: bool, + pub created_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/agents/agent_execution_steps.rs b/models/agents/agent_execution_steps.rs new file mode 100644 index 0000000..4b8a1f2 --- /dev/null +++ b/models/agents/agent_execution_steps.rs @@ -0,0 +1,24 @@ +use crate::models::common::{JsonValue, Status, StepType}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct AgentExecutionStep { + pub id: Uuid, + pub execution_id: Uuid, + pub step_index: i32, + pub step_type: StepType, + pub name: String, + pub status: Status, + pub model_id: Option, + pub tool_name: Option, + pub input: Option, + pub output: Option, + pub error_message: Option, + pub token_input_count: Option, + pub token_output_count: Option, + pub started_at: Option>, + pub finished_at: Option>, + pub created_at: DateTime, +} diff --git a/models/agents/agent_executions.rs b/models/agents/agent_executions.rs new file mode 100644 index 0000000..a698f84 --- /dev/null +++ b/models/agents/agent_executions.rs @@ -0,0 +1,25 @@ +use crate::models::common::{JsonValue, Status, TriggerType}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct AgentExecution { + pub id: Uuid, + pub agent_id: Uuid, + pub agent_version_id: Option, + pub workspace_id: Option, + pub repo_id: Option, + pub issue_id: Option, + pub pull_request_id: Option, + pub triggered_by: Option, + pub trigger_type: TriggerType, + pub status: Status, + pub input: Option, + pub output: Option, + pub error_message: Option, + pub started_at: Option>, + pub finished_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/agents/agent_feedback.rs b/models/agents/agent_feedback.rs new file mode 100644 index 0000000..737f2d6 --- /dev/null +++ b/models/agents/agent_feedback.rs @@ -0,0 +1,18 @@ +use crate::models::common::{FeedbackType, JsonValue}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct AgentFeedback { + pub id: Uuid, + pub agent_id: Uuid, + pub execution_id: Option, + pub user_id: Uuid, + pub rating: i32, + pub feedback_type: FeedbackType, + pub comment: Option, + pub metadata: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/agents/agent_schedules.rs b/models/agents/agent_schedules.rs new file mode 100644 index 0000000..59dceff --- /dev/null +++ b/models/agents/agent_schedules.rs @@ -0,0 +1,22 @@ +use crate::models::json_types::{AgentSchedulePayload, TypedJson}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct AgentSchedule { + pub id: Uuid, + pub agent_id: Uuid, + pub workspace_id: Option, + pub repo_id: Option, + pub name: String, + pub cron_expr: String, + pub timezone: String, + pub payload: Option>, + pub enabled: bool, + pub last_run_at: Option>, + pub next_run_at: Option>, + pub created_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/agents/agent_versions.rs b/models/agents/agent_versions.rs new file mode 100644 index 0000000..15b122d --- /dev/null +++ b/models/agents/agent_versions.rs @@ -0,0 +1,24 @@ +use crate::models::json_types::{AgentVersionConfig, TypedJson}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct AgentVersion { + pub id: Uuid, + pub agent_id: Uuid, + pub version: String, + pub name: Option, + pub description: Option, + pub model_id: Option, + pub system_prompt: String, + pub instructions: Option, + pub tools: Vec, + pub config: Option>, + pub changelog: Option, + pub stable: bool, + pub published_by: Uuid, + pub published_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/agents/agent_workspace_bindings.rs b/models/agents/agent_workspace_bindings.rs new file mode 100644 index 0000000..4fa5033 --- /dev/null +++ b/models/agents/agent_workspace_bindings.rs @@ -0,0 +1,18 @@ +use crate::models::common::{Permission, Role}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct AgentWorkspaceBinding { + pub id: Uuid, + pub agent_id: Uuid, + pub workspace_id: Uuid, + pub repo_id: Option, + pub role: Role, + pub permissions: Vec, + pub enabled: bool, + pub bound_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/agents/mod.rs b/models/agents/mod.rs new file mode 100644 index 0000000..54fac49 --- /dev/null +++ b/models/agents/mod.rs @@ -0,0 +1,17 @@ +pub mod agent; +pub mod agent_event_subscriptions; +pub mod agent_execution_steps; +pub mod agent_executions; +pub mod agent_feedback; +pub mod agent_schedules; +pub mod agent_versions; +pub mod agent_workspace_bindings; + +pub use agent::Agent; +pub use agent_event_subscriptions::AgentEventSubscription; +pub use agent_execution_steps::AgentExecutionStep; +pub use agent_executions::AgentExecution; +pub use agent_feedback::AgentFeedback; +pub use agent_schedules::AgentSchedule; +pub use agent_versions::AgentVersion; +pub use agent_workspace_bindings::AgentWorkspaceBinding; diff --git a/models/ais/ai_model_capabilities.rs b/models/ais/ai_model_capabilities.rs new file mode 100644 index 0000000..c218bb1 --- /dev/null +++ b/models/ais/ai_model_capabilities.rs @@ -0,0 +1,15 @@ +use crate::models::json_types::{AiModelCapabilityConfig, TypedJson}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct AiModelCapability { + pub id: Uuid, + pub ai_model_id: Uuid, + pub capability: String, + pub supported: bool, + pub config: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/ais/ai_model_health.rs b/models/ais/ai_model_health.rs new file mode 100644 index 0000000..5c4cf6d --- /dev/null +++ b/models/ais/ai_model_health.rs @@ -0,0 +1,16 @@ +use crate::models::common::Status; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct AiModelHealth { + pub id: Uuid, + pub ai_model_id: Uuid, + pub status: Status, + pub latency_ms: Option, + pub error_rate: Option, + pub last_error: Option, + pub checked_at: DateTime, + pub created_at: DateTime, +} diff --git a/models/ais/ai_model_versions.rs b/models/ais/ai_model_versions.rs new file mode 100644 index 0000000..94f1570 --- /dev/null +++ b/models/ais/ai_model_versions.rs @@ -0,0 +1,19 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct AiModelVersion { + pub id: Uuid, + pub ai_model_id: Uuid, + pub version: String, + pub provider_model_id: String, + pub context_window: Option, + pub max_output_tokens: Option, + pub changelog: Option, + pub stable: bool, + pub deprecated: bool, + pub released_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/ais/ai_models.rs b/models/ais/ai_models.rs new file mode 100644 index 0000000..7b334b1 --- /dev/null +++ b/models/ais/ai_models.rs @@ -0,0 +1,30 @@ +use crate::models::common::{AiFeature, AiModelType, Modality, PricingUnit, Provider}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct AiModel { + pub id: Uuid, + pub provider: Provider, + pub model_id: String, + pub name: String, + pub description: Option, + pub model_type: AiModelType, + pub family: Option, + pub version: Option, + pub context_window: Option, + pub max_output_tokens: Option, + pub input_modalities: Vec, + pub output_modalities: Vec, + pub supported_features: Vec, + pub pricing_unit: Option, + pub input_price_per_unit: Option, + pub output_price_per_unit: Option, + pub enabled: bool, + pub deprecated: bool, + pub released_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/models/ais/mod.rs b/models/ais/mod.rs new file mode 100644 index 0000000..10c5e23 --- /dev/null +++ b/models/ais/mod.rs @@ -0,0 +1,9 @@ +pub mod ai_model_capabilities; +pub mod ai_model_health; +pub mod ai_model_versions; +pub mod ai_models; + +pub use ai_model_capabilities::AiModelCapability; +pub use ai_model_health::AiModelHealth; +pub use ai_model_versions::AiModelVersion; +pub use ai_models::AiModel; diff --git a/models/channels/article_comments.rs b/models/channels/article_comments.rs new file mode 100644 index 0000000..da67e7f --- /dev/null +++ b/models/channels/article_comments.rs @@ -0,0 +1,18 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Discussion comment on an article (similar to blog comments). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ArticleComment { + pub id: Uuid, + pub article_id: Uuid, + pub channel_id: Uuid, + pub author_id: Uuid, + pub parent_comment_id: Option, + pub body: String, + pub edited_at: Option>, + pub deleted_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/channels/article_cross_posts.rs b/models/channels/article_cross_posts.rs new file mode 100644 index 0000000..62ef5a5 --- /dev/null +++ b/models/channels/article_cross_posts.rs @@ -0,0 +1,22 @@ +use crate::models::common::Status; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Tracks a single cross-post delivery when an article is published +/// to a follower channel/workspace. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ArticleCrossPost { + pub id: Uuid, + pub article_id: Uuid, + pub follow_id: Uuid, + pub target_workspace_id: Uuid, + pub target_channel_id: Option, + pub status: Status, + pub attempts: i32, + pub last_error: Option, + pub sent_at: Option>, + pub delivered_at: Option>, + pub failed_at: Option>, + pub created_at: DateTime, +} diff --git a/models/channels/article_reactions.rs b/models/channels/article_reactions.rs new file mode 100644 index 0000000..19dfc6d --- /dev/null +++ b/models/channels/article_reactions.rs @@ -0,0 +1,14 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Reaction on an article (emoji reactions, separate from message reactions). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ArticleReaction { + pub id: Uuid, + pub article_id: Uuid, + pub channel_id: Uuid, + pub user_id: Uuid, + pub content: String, + pub created_at: DateTime, +} diff --git a/models/channels/articles.rs b/models/channels/articles.rs new file mode 100644 index 0000000..82edfa4 --- /dev/null +++ b/models/channels/articles.rs @@ -0,0 +1,35 @@ +use crate::models::common::{ArticleStatus, JsonValue, Visibility}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Long-form article for announcement/news channels. +/// Unlike a plain Message, an article has a title, cover image, +/// publish lifecycle, and can be cross-posted to followers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct Article { + pub id: Uuid, + pub channel_id: Uuid, + pub author_id: Uuid, + pub title: String, + pub slug: String, + pub summary: Option, + pub body: String, + pub cover_image_url: Option, + pub status: ArticleStatus, + pub visibility: Visibility, + pub tags: Vec, + pub published_at: Option>, + pub published_by: Option, + pub scheduled_at: Option>, + pub unpublished_at: Option>, + pub views_count: i64, + pub comments_count: i64, + pub reactions_count: i64, + pub cross_posted: bool, + pub cross_posted_from: Option, + pub metadata: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/models/channels/channel.rs b/models/channels/channel.rs new file mode 100644 index 0000000..acec078 --- /dev/null +++ b/models/channels/channel.rs @@ -0,0 +1,43 @@ +use crate::models::common::{ + ChannelKind, ChannelType, ForumLayout, ForumSortOrder, JsonValue, Visibility, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct Channel { + pub id: Uuid, + pub workspace_id: Uuid, + pub repo_id: Option, + pub category_id: Option, + pub created_by: Uuid, + pub name: String, + pub topic: Option, + pub description: Option, + pub channel_type: ChannelType, + pub channel_kind: ChannelKind, + pub visibility: Visibility, + pub position: Option, + pub nsfw: bool, + pub archived: bool, + pub read_only: bool, + pub bitrate: Option, + pub user_limit: Option, + pub rtc_region: Option, + pub default_auto_archive_duration: Option, + pub default_reaction_emoji: Option, + pub default_sort_order: Option, + pub default_forum_layout: Option, + pub require_tag: Option, + pub available_tags: Option, + pub default_thread_rate_limit: Option, + pub rate_limit_per_user: Option, + pub parent_channel_id: Option, + pub last_message_id: Option, + pub last_message_at: Option>, + pub archived_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/models/channels/channel_categories.rs b/models/channels/channel_categories.rs new file mode 100644 index 0000000..8ca1405 --- /dev/null +++ b/models/channels/channel_categories.rs @@ -0,0 +1,15 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ChannelCategory { + pub id: Uuid, + pub workspace_id: Uuid, + pub name: String, + pub position: i32, + pub collapsed: bool, + pub created_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/channels/channel_events.rs b/models/channels/channel_events.rs new file mode 100644 index 0000000..f03ae68 --- /dev/null +++ b/models/channels/channel_events.rs @@ -0,0 +1,18 @@ +use crate::models::common::{EventType, JsonValue, TargetType}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ChannelEvent { + pub id: Uuid, + pub channel_id: Uuid, + pub actor_id: Option, + pub event_type: EventType, + pub target_type: Option, + pub target_id: Option, + pub old_value: Option, + pub new_value: Option, + pub metadata: Option, + pub created_at: DateTime, +} diff --git a/models/channels/channel_follows.rs b/models/channels/channel_follows.rs new file mode 100644 index 0000000..3be70ed --- /dev/null +++ b/models/channels/channel_follows.rs @@ -0,0 +1,23 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Follow relationship on an announcement channel. +/// Allows another workspace or channel to receive cross-posts +/// when articles are published. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ChannelFollow { + pub id: Uuid, + pub source_channel_id: Uuid, + pub target_workspace_id: Uuid, + pub target_channel_id: Option, + pub webhook_url: Option, + pub webhook_secret_ciphertext: Option, + pub enabled: bool, + pub followed_by: Uuid, + pub unfollowed_at: Option>, + pub last_delivery_at: Option>, + pub last_delivery_status: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/channels/channel_invitations.rs b/models/channels/channel_invitations.rs new file mode 100644 index 0000000..bbfccab --- /dev/null +++ b/models/channels/channel_invitations.rs @@ -0,0 +1,20 @@ +use crate::models::common::Role; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ChannelInvitation { + pub id: Uuid, + pub channel_id: Uuid, + pub workspace_id: Uuid, + pub invited_user_id: Option, + pub email: Option, + pub role: Role, + pub token_hash: String, + pub invited_by: Uuid, + pub accepted_at: Option>, + pub revoked_at: Option>, + pub expires_at: DateTime, + pub created_at: DateTime, +} diff --git a/models/channels/channel_member_roles.rs b/models/channels/channel_member_roles.rs new file mode 100644 index 0000000..d9b6525 --- /dev/null +++ b/models/channels/channel_member_roles.rs @@ -0,0 +1,17 @@ +use crate::models::common::Permission; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ChannelMemberRole { + pub id: Uuid, + pub channel_id: Uuid, + pub name: String, + pub description: Option, + pub permissions: Vec, + pub assignable: bool, + pub created_by: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/channels/channel_members.rs b/models/channels/channel_members.rs new file mode 100644 index 0000000..e3588f4 --- /dev/null +++ b/models/channels/channel_members.rs @@ -0,0 +1,21 @@ +use crate::models::common::{Role, Status}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ChannelMember { + pub id: Uuid, + pub channel_id: Uuid, + pub user_id: Uuid, + pub role: Role, + pub status: Status, + pub muted: bool, + pub pinned: bool, + pub last_read_message_id: Option, + pub last_read_at: Option>, + pub joined_at: Option>, + pub left_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/channels/channel_permission_overwrites.rs b/models/channels/channel_permission_overwrites.rs new file mode 100644 index 0000000..72746d2 --- /dev/null +++ b/models/channels/channel_permission_overwrites.rs @@ -0,0 +1,17 @@ +use crate::models::common::{OverwriteTarget, Permission}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ChannelPermissionOverwrite { + pub id: Uuid, + pub channel_id: Uuid, + pub target_type: OverwriteTarget, + pub target_id: Uuid, + pub allow: Vec, + pub deny: Vec, + pub created_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/channels/channel_repo_links.rs b/models/channels/channel_repo_links.rs new file mode 100644 index 0000000..316dbc6 --- /dev/null +++ b/models/channels/channel_repo_links.rs @@ -0,0 +1,17 @@ +use crate::models::common::{EventType, LinkType}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ChannelRepoLink { + pub id: Uuid, + pub channel_id: Uuid, + pub repo_id: Uuid, + pub link_type: LinkType, + pub notify_events: Vec, + pub active: bool, + pub created_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/channels/channel_slash_commands.rs b/models/channels/channel_slash_commands.rs new file mode 100644 index 0000000..1c004c9 --- /dev/null +++ b/models/channels/channel_slash_commands.rs @@ -0,0 +1,20 @@ +use crate::models::common::Scope; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ChannelSlashCommand { + pub id: Uuid, + pub channel_id: Option, + pub workspace_id: Uuid, + pub command: String, + pub description: Option, + pub request_url: String, + pub secret_ciphertext: Option, + pub scopes: Vec, + pub enabled: bool, + pub created_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/channels/channel_stats.rs b/models/channels/channel_stats.rs new file mode 100644 index 0000000..31480cc --- /dev/null +++ b/models/channels/channel_stats.rs @@ -0,0 +1,16 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ChannelStats { + pub channel_id: Uuid, + pub members_count: i64, + pub messages_count: i64, + pub threads_count: i64, + pub reactions_count: i64, + pub mentions_count: i64, + pub files_count: i64, + pub last_activity_at: Option>, + pub updated_at: DateTime, +} diff --git a/models/channels/channel_webhooks.rs b/models/channels/channel_webhooks.rs new file mode 100644 index 0000000..89a9b20 --- /dev/null +++ b/models/channels/channel_webhooks.rs @@ -0,0 +1,20 @@ +use crate::models::common::EventType; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ChannelWebhook { + pub id: Uuid, + pub channel_id: Uuid, + pub name: String, + pub url: String, + pub secret_ciphertext: Option, + pub events: Vec, + pub active: bool, + pub last_delivery_status: Option, + pub last_delivery_at: Option>, + pub created_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/channels/custom_emojis.rs b/models/channels/custom_emojis.rs new file mode 100644 index 0000000..66c237a --- /dev/null +++ b/models/channels/custom_emojis.rs @@ -0,0 +1,16 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct CustomEmoji { + pub id: Uuid, + pub workspace_id: Uuid, + pub name: String, + pub url: String, + pub animated: bool, + pub managed: bool, + pub created_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/channels/forum_tags.rs b/models/channels/forum_tags.rs new file mode 100644 index 0000000..7ce66b0 --- /dev/null +++ b/models/channels/forum_tags.rs @@ -0,0 +1,18 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Forum channel post tags (similar to Discord forum tags). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ForumTag { + pub id: Uuid, + pub channel_id: Uuid, + pub name: String, + pub emoji_id: Option, + pub emoji_name: Option, + pub moderated: bool, + pub position: i32, + pub created_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/channels/im_integrations.rs b/models/channels/im_integrations.rs new file mode 100644 index 0000000..d11911c --- /dev/null +++ b/models/channels/im_integrations.rs @@ -0,0 +1,24 @@ +use crate::models::common::{JsonValue, Provider, SyncDirection}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ImIntegration { + pub id: Uuid, + pub workspace_id: Uuid, + pub provider: Provider, + pub name: String, + pub external_workspace_id: Option, + pub internal_channel_id: Option, + pub external_channel_id: Option, + pub bot_token_ciphertext: Option, + pub webhook_url: Option, + pub sync_direction: SyncDirection, + pub user_mapping: Option, + pub enabled: bool, + pub installed_by: Uuid, + pub last_sync_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/channels/message.rs b/models/channels/message.rs new file mode 100644 index 0000000..737bf24 --- /dev/null +++ b/models/channels/message.rs @@ -0,0 +1,23 @@ +use crate::models::common::{JsonValue, MessageType}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct Message { + pub id: Uuid, + pub channel_id: Uuid, + pub author_id: Uuid, + pub thread_id: Option, + pub reply_to_message_id: Option, + pub seq: i64, + pub message_type: MessageType, + pub body: String, + pub metadata: Option, + pub pinned: bool, + pub system: bool, + pub edited_at: Option>, + pub deleted_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/channels/message_attachments.rs b/models/channels/message_attachments.rs new file mode 100644 index 0000000..20a22c9 --- /dev/null +++ b/models/channels/message_attachments.rs @@ -0,0 +1,21 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct MessageAttachment { + pub id: Uuid, + pub message_id: Uuid, + pub channel_id: Uuid, + pub filename: String, + pub url: String, + pub proxy_url: Option, + pub size_bytes: i64, + pub mime_type: String, + pub width: Option, + pub height: Option, + pub duration_ms: Option, + pub thumbnail_url: Option, + pub blurhash: Option, + pub created_at: DateTime, +} diff --git a/models/channels/message_bookmarks.rs b/models/channels/message_bookmarks.rs new file mode 100644 index 0000000..d42d2da --- /dev/null +++ b/models/channels/message_bookmarks.rs @@ -0,0 +1,14 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct MessageBookmark { + pub id: Uuid, + pub message_id: Uuid, + pub channel_id: Uuid, + pub user_id: Uuid, + pub note: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/channels/message_drafts.rs b/models/channels/message_drafts.rs new file mode 100644 index 0000000..0be57e6 --- /dev/null +++ b/models/channels/message_drafts.rs @@ -0,0 +1,17 @@ +use crate::models::common::JsonValue; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct MessageDraft { + pub id: Uuid, + pub user_id: Uuid, + pub channel_id: Uuid, + pub thread_id: Option, + pub reply_to_message_id: Option, + pub content: String, + pub attachments: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/channels/message_edit_history.rs b/models/channels/message_edit_history.rs new file mode 100644 index 0000000..b48308a --- /dev/null +++ b/models/channels/message_edit_history.rs @@ -0,0 +1,13 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct MessageEditHistory { + pub id: Uuid, + pub message_id: Uuid, + pub channel_id: Uuid, + pub previous_body: String, + pub edited_by: Uuid, + pub edited_at: DateTime, +} diff --git a/models/channels/message_embeds.rs b/models/channels/message_embeds.rs new file mode 100644 index 0000000..ed94b2f --- /dev/null +++ b/models/channels/message_embeds.rs @@ -0,0 +1,34 @@ +use crate::models::common::{EmbedType, JsonValue}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct MessageEmbed { + pub id: Uuid, + pub message_id: Uuid, + pub embed_type: EmbedType, + pub title: Option, + pub description: Option, + pub url: Option, + pub author_name: Option, + pub author_url: Option, + pub author_icon_url: Option, + pub thumbnail_url: Option, + pub thumbnail_width: Option, + pub thumbnail_height: Option, + pub image_url: Option, + pub image_width: Option, + pub image_height: Option, + pub video_url: Option, + pub video_width: Option, + pub video_height: Option, + pub color: Option, + pub fields: Option, + pub footer_text: Option, + pub footer_icon_url: Option, + pub provider_name: Option, + pub provider_url: Option, + pub timestamp: Option>, + pub created_at: DateTime, +} diff --git a/models/channels/message_mentions.rs b/models/channels/message_mentions.rs new file mode 100644 index 0000000..837a628 --- /dev/null +++ b/models/channels/message_mentions.rs @@ -0,0 +1,14 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct MessageMention { + pub id: Uuid, + pub message_id: Uuid, + pub channel_id: Uuid, + pub mentioned_user_id: Uuid, + pub mentioned_by: Uuid, + pub read_at: Option>, + pub created_at: DateTime, +} diff --git a/models/channels/message_pins.rs b/models/channels/message_pins.rs new file mode 100644 index 0000000..67e2586 --- /dev/null +++ b/models/channels/message_pins.rs @@ -0,0 +1,12 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct MessagePin { + pub id: Uuid, + pub message_id: Uuid, + pub channel_id: Uuid, + pub pinned_by: Uuid, + pub pinned_at: DateTime, +} diff --git a/models/channels/message_poll_options.rs b/models/channels/message_poll_options.rs new file mode 100644 index 0000000..84d7348 --- /dev/null +++ b/models/channels/message_poll_options.rs @@ -0,0 +1,16 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Poll option. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct MessagePollOption { + pub id: Uuid, + pub poll_id: Uuid, + pub position: i32, + pub text: String, + pub emoji_id: Option, + pub emoji_name: Option, + pub vote_count: i64, + pub created_at: DateTime, +} diff --git a/models/channels/message_poll_votes.rs b/models/channels/message_poll_votes.rs new file mode 100644 index 0000000..52bedef --- /dev/null +++ b/models/channels/message_poll_votes.rs @@ -0,0 +1,13 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// User vote record for a poll option. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct MessagePollVote { + pub id: Uuid, + pub poll_id: Uuid, + pub option_id: Uuid, + pub user_id: Uuid, + pub voted_at: DateTime, +} diff --git a/models/channels/message_polls.rs b/models/channels/message_polls.rs new file mode 100644 index 0000000..13269eb --- /dev/null +++ b/models/channels/message_polls.rs @@ -0,0 +1,22 @@ +use crate::models::common::{JsonValue, PollLayout}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Message poll (similar to Discord Polls). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct MessagePoll { + pub id: Uuid, + pub message_id: Uuid, + pub channel_id: Uuid, + pub question: String, + pub description: Option, + pub layout: PollLayout, + pub allow_multiselect: bool, + pub duration_hours: Option, + pub ends_at: Option>, + pub total_votes: i64, + pub metadata: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/channels/message_reactions.rs b/models/channels/message_reactions.rs new file mode 100644 index 0000000..7241d35 --- /dev/null +++ b/models/channels/message_reactions.rs @@ -0,0 +1,13 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct MessageReaction { + pub id: Uuid, + pub message_id: Uuid, + pub channel_id: Uuid, + pub user_id: Uuid, + pub content: String, + pub created_at: DateTime, +} diff --git a/models/channels/message_threads.rs b/models/channels/message_threads.rs new file mode 100644 index 0000000..a534245 --- /dev/null +++ b/models/channels/message_threads.rs @@ -0,0 +1,27 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct MessageThread { + pub id: Uuid, + pub channel_id: Uuid, + pub root_message_id: Uuid, + pub created_by: Uuid, + pub replies_count: i64, + pub participants_count: i64, + pub last_reply_message_id: Option, + pub last_reply_at: Option>, + pub resolved: bool, + pub resolved_by: Option, + pub resolved_at: Option>, + // ── Forum post specific ── + pub title: Option, + pub tags: Vec, + pub pinned: bool, + pub locked: bool, + pub rate_limit_per_user: Option, + pub auto_archive_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/channels/mod.rs b/models/channels/mod.rs new file mode 100644 index 0000000..e74dc2a --- /dev/null +++ b/models/channels/mod.rs @@ -0,0 +1,73 @@ +pub mod article_comments; +pub mod article_cross_posts; +pub mod article_reactions; +pub mod articles; +pub mod channel; +pub mod channel_categories; +pub mod channel_events; +pub mod channel_follows; +pub mod channel_invitations; +pub mod channel_member_roles; +pub mod channel_members; +pub mod channel_permission_overwrites; +pub mod channel_repo_links; +pub mod channel_slash_commands; +pub mod channel_stats; +pub mod channel_webhooks; +pub mod custom_emojis; +pub mod forum_tags; +pub mod im_integrations; +pub mod message; +pub mod message_attachments; +pub mod message_bookmarks; +pub mod message_drafts; +pub mod message_edit_history; +pub mod message_embeds; +pub mod message_mentions; +pub mod message_pins; +pub mod message_poll_options; +pub mod message_poll_votes; +pub mod message_polls; +pub mod message_reactions; +pub mod message_threads; +pub mod saved_messages; +pub mod stages; +pub mod thread_read_states; +pub mod voice_participants; + +pub use article_comments::ArticleComment; +pub use article_cross_posts::ArticleCrossPost; +pub use article_reactions::ArticleReaction; +pub use articles::Article; +pub use channel::Channel; +pub use channel_categories::ChannelCategory; +pub use channel_events::ChannelEvent; +pub use channel_follows::ChannelFollow; +pub use channel_invitations::ChannelInvitation; +pub use channel_member_roles::ChannelMemberRole; +pub use channel_members::ChannelMember; +pub use channel_permission_overwrites::ChannelPermissionOverwrite; +pub use channel_repo_links::ChannelRepoLink; +pub use channel_slash_commands::ChannelSlashCommand; +pub use channel_stats::ChannelStats; +pub use channel_webhooks::ChannelWebhook; +pub use custom_emojis::CustomEmoji; +pub use forum_tags::ForumTag; +pub use im_integrations::ImIntegration; +pub use message::Message; +pub use message_attachments::MessageAttachment; +pub use message_bookmarks::MessageBookmark; +pub use message_drafts::MessageDraft; +pub use message_edit_history::MessageEditHistory; +pub use message_embeds::MessageEmbed; +pub use message_mentions::MessageMention; +pub use message_pins::MessagePin; +pub use message_poll_options::MessagePollOption; +pub use message_poll_votes::MessagePollVote; +pub use message_polls::MessagePoll; +pub use message_reactions::MessageReaction; +pub use message_threads::MessageThread; +pub use saved_messages::SavedMessage; +pub use stages::Stage; +pub use thread_read_states::ThreadReadState; +pub use voice_participants::VoiceParticipant; diff --git a/models/channels/saved_messages.rs b/models/channels/saved_messages.rs new file mode 100644 index 0000000..a8449f8 --- /dev/null +++ b/models/channels/saved_messages.rs @@ -0,0 +1,13 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct SavedMessage { + pub id: Uuid, + pub user_id: Uuid, + pub message_id: Uuid, + pub channel_id: Uuid, + pub note: Option, + pub created_at: DateTime, +} diff --git a/models/channels/stages.rs b/models/channels/stages.rs new file mode 100644 index 0000000..d70f33c --- /dev/null +++ b/models/channels/stages.rs @@ -0,0 +1,20 @@ +use crate::models::common::StagePrivacyLevel; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Voice stage — similar to Discord Stage Channel. +/// Only one active stage instance per channel at a time. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct Stage { + pub id: Uuid, + pub channel_id: Uuid, + pub topic: String, + pub privacy_level: StagePrivacyLevel, + pub discoverable: bool, + pub started_by: Uuid, + pub started_at: DateTime, + pub ended_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/channels/thread_read_states.rs b/models/channels/thread_read_states.rs new file mode 100644 index 0000000..6c6ebbe --- /dev/null +++ b/models/channels/thread_read_states.rs @@ -0,0 +1,14 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ThreadReadState { + pub id: Uuid, + pub user_id: Uuid, + pub thread_id: Uuid, + pub channel_id: Uuid, + pub last_read_message_id: Option, + pub last_read_at: Option>, + pub updated_at: DateTime, +} diff --git a/models/channels/voice_participants.rs b/models/channels/voice_participants.rs new file mode 100644 index 0000000..87f93e3 --- /dev/null +++ b/models/channels/voice_participants.rs @@ -0,0 +1,21 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Real-time voice/video channel participant state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct VoiceParticipant { + pub id: Uuid, + pub channel_id: Uuid, + pub user_id: Uuid, + pub session_id: Option, + pub deafened: bool, + pub muted: bool, + pub self_deafened: bool, + pub self_muted: bool, + pub self_video: bool, + pub streaming: bool, + pub speaking: bool, + pub joined_at: DateTime, + pub left_at: Option>, +} diff --git a/models/common.rs b/models/common.rs new file mode 100644 index 0000000..4268cbc --- /dev/null +++ b/models/common.rs @@ -0,0 +1,794 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use sqlx::encode::IsNull; +use sqlx::error::BoxDynError; +use sqlx::postgres::{PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueRef}; +use sqlx::{Decode, Encode, Postgres, Type}; +use std::fmt; +use std::str::FromStr; + +pub type JsonValue = serde_json::Value; + +macro_rules! string_enum { + ( + $(#[$meta:meta])* + pub enum $name:ident { + $( $variant:ident => $value:literal ),+ $(,)? + } + ) => { + $(#[$meta])* + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, utoipa::ToSchema)] + pub enum $name { + $( $variant, )+ + } + + impl $name { + pub const fn as_str(self) -> &'static str { + match self { + $( Self::$variant => $value, )+ + } + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } + } + + impl FromStr for $name { + type Err = (); + + fn from_str(value: &str) -> Result { + Ok(match value { + $( $value => Self::$variant, )+ + _ => Self::Unknown, + }) + } + } + + impl Serialize for $name { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Ok(value.parse().unwrap_or(Self::Unknown)) + } + } + + impl Type for $name { + fn type_info() -> PgTypeInfo { + >::type_info() + } + + fn compatible(ty: &PgTypeInfo) -> bool { + >::compatible(ty) + } + } + + impl<'r> Decode<'r, Postgres> for $name { + fn decode(value: PgValueRef<'r>) -> Result { + let value = >::decode(value)?; + Ok(value.parse().unwrap_or(Self::Unknown)) + } + } + + impl<'q> Encode<'q, Postgres> for $name { + fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> Result { + <&str as Encode>::encode_by_ref(&self.as_str(), buf) + } + + fn size_hint(&self) -> usize { + <&str as Encode>::size_hint(&self.as_str()) + } + } + + impl PgHasArrayType for $name { + fn array_type_info() -> PgTypeInfo { + ::array_type_info() + } + + fn array_compatible(ty: &PgTypeInfo) -> bool { + ::array_compatible(ty) + } + } + }; +} + +string_enum! { + /// Cross-domain lifecycle/status values persisted as snake_case text. + pub enum Status { + Active => "active", + Inactive => "inactive", + Enabled => "enabled", + Disabled => "disabled", + Pending => "pending", + Queued => "queued", + Running => "running", + Processing => "processing", + Completed => "completed", + Success => "success", + Failed => "failed", + Error => "error", + Canceled => "canceled", + Cancelled => "cancelled", + Draft => "draft", + Open => "open", + Closed => "closed", + Merged => "merged", + Archived => "archived", + Deleted => "deleted", + Revoked => "revoked", + Expired => "expired", + Accepted => "accepted", + Rejected => "rejected", + Approved => "approved", + Healthy => "healthy", + Degraded => "degraded", + Unhealthy => "unhealthy", + Online => "online", + Offline => "offline", + Added => "added", + Modified => "modified", + Removed => "removed", + Renamed => "renamed", + Copied => "copied", + Unknown => "unknown", + } +} + +string_enum! { + pub enum State { + Open => "open", + Closed => "closed", + Merged => "merged", + Draft => "draft", + Pending => "pending", + Queued => "queued", + Running => "running", + Success => "success", + Failure => "failure", + Failed => "failed", + Error => "error", + Skipped => "skipped", + Blocked => "blocked", + Clean => "clean", + Dirty => "dirty", + Unknown => "unknown", + } +} + +string_enum! { + pub enum Role { + Owner => "owner", + Admin => "admin", + Maintainer => "maintainer", + Member => "member", + Guest => "guest", + Viewer => "viewer", + Editor => "editor", + Contributor => "contributor", + Moderator => "moderator", + User => "user", + Bot => "bot", + Agent => "agent", + Assistant => "assistant", + System => "system", + Author => "author", + Reviewer => "reviewer", + Assignee => "assignee", + Unknown => "unknown", + } +} + +string_enum! { + pub enum Visibility { + Public => "public", + Private => "private", + Internal => "internal", + Workspace => "workspace", + Protected => "protected", + Hidden => "hidden", + Secret => "secret", + Unknown => "unknown", + } +} + +string_enum! { + pub enum Priority { + None => "none", + Low => "low", + Medium => "medium", + High => "high", + Critical => "critical", + Urgent => "urgent", + Unknown => "unknown", + } +} + +string_enum! { + pub enum MessageRole { + System => "system", + User => "user", + Assistant => "assistant", + Agent => "agent", + Tool => "tool", + Developer => "developer", + Unknown => "unknown", + } +} + +string_enum! { + pub enum MessageType { + Text => "text", + Markdown => "markdown", + Html => "html", + System => "system", + Event => "event", + File => "file", + Image => "image", + Code => "code", + ToolCall => "tool_call", + ToolResult => "tool_result", + Audio => "audio", + Video => "video", + Unknown => "unknown", + } +} + +string_enum! { + pub enum ContentFormat { + PlainText => "plain_text", + Markdown => "markdown", + Html => "html", + Json => "json", + Unknown => "unknown", + } +} + +string_enum! { + pub enum EventType { + Created => "created", + Updated => "updated", + Deleted => "deleted", + Closed => "closed", + Reopened => "reopened", + Assigned => "assigned", + Unassigned => "unassigned", + Labeled => "labeled", + Unlabeled => "unlabeled", + Commented => "commented", + Mentioned => "mentioned", + Pushed => "pushed", + Merged => "merged", + Reviewed => "reviewed", + Archived => "archived", + Restored => "restored", + Joined => "joined", + Left => "left", + Invited => "invited", + Accepted => "accepted", + Revoked => "revoked", + DraftReady => "draft_ready", + Unknown => "unknown", + } +} + +string_enum! { + pub enum TargetType { + User => "user", + Workspace => "workspace", + Repo => "repo", + Issue => "issue", + PullRequest => "pull_request", + Channel => "channel", + Message => "message", + Conversation => "conversation", + Agent => "agent", + AiModel => "ai_model", + Commit => "commit", + Branch => "branch", + Release => "release", + Unknown => "unknown", + } +} + +string_enum! { + pub enum NotificationType { + Mention => "mention", + Assignment => "assignment", + Review => "review", + Comment => "comment", + Build => "build", + Security => "security", + Billing => "billing", + System => "system", + Digest => "digest", + Unknown => "unknown", + } +} + +string_enum! { + pub enum DeliveryChannel { + Email => "email", + Web => "web", + Push => "push", + Slack => "slack", + Discord => "discord", + Webhook => "webhook", + Sms => "sms", + Unknown => "unknown", + } +} + +string_enum! { + pub enum SubscriptionLevel { + All => "all", + Participating => "participating", + Mention => "mention", + Watch => "watch", + Ignore => "ignore", + None => "none", + Custom => "custom", + Unknown => "unknown", + } +} + +string_enum! { + pub enum Provider { + Github => "github", + Gitlab => "gitlab", + Google => "google", + Slack => "slack", + Discord => "discord", + Email => "email", + Web => "web", + Push => "push", + Openai => "openai", + Anthropic => "anthropic", + Gemini => "gemini", + Ollama => "ollama", + Azure => "azure", + Aws => "aws", + Stripe => "stripe", + Unknown => "unknown", + } +} + +string_enum! { + pub enum AgentType { + Assistant => "assistant", + Automation => "automation", + Reviewer => "reviewer", + Triage => "triage", + Scheduler => "scheduler", + Bot => "bot", + Unknown => "unknown", + } +} + +string_enum! { + pub enum AiModelType { + Chat => "chat", + Completion => "completion", + Embedding => "embedding", + Reranker => "reranker", + Image => "image", + Audio => "audio", + Multimodal => "multimodal", + Moderation => "moderation", + Unknown => "unknown", + } +} + +string_enum! { + pub enum ConversationType { + Chat => "chat", + Issue => "issue", + PullRequest => "pull_request", + Review => "review", + Agent => "agent", + Support => "support", + Incident => "incident", + Unknown => "unknown", + } +} + +string_enum! { + pub enum StepType { + Model => "model", + Tool => "tool", + Reasoning => "reasoning", + Retrieval => "retrieval", + Planning => "planning", + Execution => "execution", + Validation => "validation", + Unknown => "unknown", + } +} + +string_enum! { + pub enum TriggerType { + Manual => "manual", + Schedule => "schedule", + Webhook => "webhook", + Event => "event", + Issue => "issue", + PullRequest => "pull_request", + Push => "push", + Message => "message", + Unknown => "unknown", + } +} + +string_enum! { + pub enum RelationType { + Closes => "closes", + Fixes => "fixes", + References => "references", + Blocks => "blocks", + Duplicates => "duplicates", + Related => "related", + Implements => "implements", + Mentions => "mentions", + Resolves => "resolves", + Unknown => "unknown", + } +} + +string_enum! { + pub enum RequestType { + Join => "join", + Invite => "invite", + Access => "access", + Approval => "approval", + Billing => "billing", + Delete => "delete", + Transfer => "transfer", + Unknown => "unknown", + } +} + +string_enum! { + pub enum DeviceType { + Desktop => "desktop", + Laptop => "laptop", + Mobile => "mobile", + Tablet => "tablet", + Browser => "browser", + Cli => "cli", + Api => "api", + Unknown => "unknown", + } +} + +string_enum! { + pub enum KeyType { + Rsa => "rsa", + Ed25519 => "ed25519", + Ecdsa => "ecdsa", + Dsa => "dsa", + Unknown => "unknown", + } +} + +string_enum! { + pub enum PasswordAlgorithm { + Argon2id => "argon2id", + Bcrypt => "bcrypt", + Scrypt => "scrypt", + Pbkdf2 => "pbkdf2", + Unknown => "unknown", + } +} + +string_enum! { + pub enum DigestFrequency { + Never => "never", + Daily => "daily", + Weekly => "weekly", + Monthly => "monthly", + Realtime => "realtime", + Unknown => "unknown", + } +} + +string_enum! { + pub enum GitService { + Local => "local", + Gitea => "gitea", + Gitlab => "gitlab", + Github => "github", + Unknown => "unknown", + } +} + +string_enum! { + pub enum FeedbackType { + Bug => "bug", + Quality => "quality", + Safety => "safety", + Performance => "performance", + Suggestion => "suggestion", + Other => "other", + Unknown => "unknown", + } +} + +string_enum! { + pub enum ParticipantType { + User => "user", + Agent => "agent", + Bot => "bot", + System => "system", + Unknown => "unknown", + } +} + +string_enum! { + pub enum SummaryType { + Short => "short", + Detailed => "detailed", + Rolling => "rolling", + Final => "final", + ActionItems => "action_items", + Unknown => "unknown", + } +} + +string_enum! { + pub enum LinkType { + Primary => "primary", + Mirror => "mirror", + Notification => "notification", + Automation => "automation", + Related => "related", + Unknown => "unknown", + } +} + +string_enum! { + pub enum ChannelType { + Public => "public", + Private => "private", + Direct => "direct", + Group => "group", + Repo => "repo", + System => "system", + Unknown => "unknown", + } +} + +string_enum! { + pub enum MergeStrategyKind { + Merge => "merge", + Squash => "squash", + Rebase => "rebase", + FastForward => "fast_forward", + Unknown => "unknown", + } +} + +string_enum! { + pub enum PricingUnit { + Token => "token", + OneKTokens => "1k_tokens", + OneMTokens => "1m_tokens", + Request => "request", + Image => "image", + Minute => "minute", + Unknown => "unknown", + } +} + +string_enum! { + pub enum Theme { + System => "system", + Light => "light", + Dark => "dark", + Unknown => "unknown", + } +} + +string_enum! { + pub enum ColorScheme { + System => "system", + Light => "light", + Dark => "dark", + HighContrast => "high_contrast", + Unknown => "unknown", + } +} + +string_enum! { + pub enum Density { + Compact => "compact", + Comfortable => "comfortable", + Spacious => "spacious", + Unknown => "unknown", + } +} + +string_enum! { + pub enum FontSize { + Small => "small", + Medium => "medium", + Large => "large", + ExtraLarge => "extra_large", + Unknown => "unknown", + } +} + +string_enum! { + pub enum Scope { + Read => "read", + Write => "write", + Admin => "admin", + RepoRead => "repo:read", + RepoWrite => "repo:write", + IssueRead => "issue:read", + IssueWrite => "issue:write", + PullRequestRead => "pull_request:read", + PullRequestWrite => "pull_request:write", + WorkspaceRead => "workspace:read", + WorkspaceWrite => "workspace:write", + UserRead => "user:read", + UserWrite => "user:write", + Unknown => "unknown", + } +} + +string_enum! { + pub enum Permission { + Read => "read", + Write => "write", + Admin => "admin", + Execute => "execute", + ManageMembers => "manage_members", + ManageSettings => "manage_settings", + ManageWebhooks => "manage_webhooks", + ManageBilling => "manage_billing", + Unknown => "unknown", + } +} + +string_enum! { + pub enum Modality { + Text => "text", + Image => "image", + Audio => "audio", + Video => "video", + File => "file", + Unknown => "unknown", + } +} + +string_enum! { + pub enum AiFeature { + Streaming => "streaming", + ToolCalling => "tool_calling", + JsonMode => "json_mode", + Vision => "vision", + Audio => "audio", + Reasoning => "reasoning", + Caching => "caching", + Unknown => "unknown", + } +} + +string_enum! { + pub enum PresenceStatus { + Online => "online", + Idle => "idle", + Dnd => "dnd", + Invisible => "invisible", + Offline => "offline", + Away => "away", + Unknown => "unknown", + } +} + +string_enum! { + pub enum ActivityType { + Playing => "playing", + Listening => "listening", + Watching => "watching", + Streaming => "streaming", + Competing => "competing", + Custom => "custom", + Unknown => "unknown", + } +} + +string_enum! { + pub enum SyncDirection { + Inbound => "inbound", + Outbound => "outbound", + Bidirectional => "bidirectional", + Unknown => "unknown", + } +} + +string_enum! { + pub enum OverwriteTarget { + User => "user", + Role => "role", + Unknown => "unknown", + } +} + +string_enum! { + pub enum EmbedType { + Link => "link", + Article => "article", + Image => "image", + Video => "video", + Rich => "rich", + Unknown => "unknown", + } +} + +string_enum! { + pub enum ChannelKind { + Text => "text", + Voice => "voice", + Stage => "stage", + Forum => "forum", + Announcement => "announcement", + Unknown => "unknown", + } +} + +string_enum! { + pub enum ForumSortOrder { + LatestActivity => "latest_activity", + CreationDate => "creation_date", + Unknown => "unknown", + } +} + +string_enum! { + pub enum ForumLayout { + Default => "default", + ListView => "list_view", + GalleryView => "gallery_view", + Unknown => "unknown", + } +} + +string_enum! { + pub enum StagePrivacyLevel { + Public => "public", + GuildOnly => "guild_only", + Unknown => "unknown", + } +} + +string_enum! { + pub enum PollLayout { + Default => "default", + SingleChoice => "single_choice", + MultipleChoice => "multiple_choice", + Unknown => "unknown", + } +} + +string_enum! { + pub enum ArticleStatus { + Draft => "draft", + Scheduled => "scheduled", + Published => "published", + Archived => "archived", + Unpublished => "unpublished", + Unknown => "unknown", + } +} diff --git a/models/conversations/conversation.rs b/models/conversations/conversation.rs new file mode 100644 index 0000000..893b7ce --- /dev/null +++ b/models/conversations/conversation.rs @@ -0,0 +1,29 @@ +use crate::models::common::{ConversationType, JsonValue, Status, Visibility}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct Conversation { + pub id: Uuid, + pub workspace_id: Option, + pub repo_id: Option, + pub issue_id: Option, + pub pull_request_id: Option, + pub agent_id: Option, + pub created_by: Uuid, + pub title: String, + pub description: Option, + pub conversation_type: ConversationType, + pub status: Status, + pub visibility: Visibility, + pub pinned: bool, + pub archived: bool, + pub metadata: Option, + pub last_message_id: Option, + pub last_message_at: Option>, + pub archived_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/models/conversations/conversation_attachments.rs b/models/conversations/conversation_attachments.rs new file mode 100644 index 0000000..cf1e00b --- /dev/null +++ b/models/conversations/conversation_attachments.rs @@ -0,0 +1,21 @@ +use crate::models::json_types::{ConversationAttachmentMetadata, TypedJson}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ConversationAttachment { + pub id: Uuid, + pub conversation_id: Uuid, + pub message_id: Option, + pub file_id: Option, + pub uploaded_by: Option, + pub name: String, + pub content_type: Option, + pub size_bytes: i64, + pub storage_path: Option, + pub url: Option, + pub metadata: Option>, + pub created_at: DateTime, + pub deleted_at: Option>, +} diff --git a/models/conversations/conversation_bookmarks.rs b/models/conversations/conversation_bookmarks.rs new file mode 100644 index 0000000..7ccdc5c --- /dev/null +++ b/models/conversations/conversation_bookmarks.rs @@ -0,0 +1,15 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ConversationBookmark { + pub id: Uuid, + pub conversation_id: Uuid, + pub message_id: Option, + pub user_id: Uuid, + pub title: Option, + pub note: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/conversations/conversation_messages.rs b/models/conversations/conversation_messages.rs new file mode 100644 index 0000000..a7ba924 --- /dev/null +++ b/models/conversations/conversation_messages.rs @@ -0,0 +1,26 @@ +use crate::models::common::{ContentFormat, JsonValue, MessageRole, MessageType, Status}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ConversationMessage { + pub id: Uuid, + pub conversation_id: Uuid, + pub parent_message_id: Option, + pub author_id: Option, + pub agent_id: Option, + pub ai_model_id: Option, + pub role: MessageRole, + pub message_type: MessageType, + pub content: String, + pub content_format: ContentFormat, + pub status: Status, + pub metadata: Option, + pub token_input_count: Option, + pub token_output_count: Option, + pub edited_at: Option>, + pub deleted_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/conversations/conversation_participants.rs b/models/conversations/conversation_participants.rs new file mode 100644 index 0000000..4b8beeb --- /dev/null +++ b/models/conversations/conversation_participants.rs @@ -0,0 +1,22 @@ +use crate::models::common::{ParticipantType, Role, Status}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ConversationParticipant { + pub id: Uuid, + pub conversation_id: Uuid, + pub user_id: Option, + pub agent_id: Option, + pub role: Role, + pub participant_type: ParticipantType, + pub status: Status, + pub muted: bool, + pub last_read_message_id: Option, + pub last_read_at: Option>, + pub joined_at: Option>, + pub left_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/conversations/conversation_summaries.rs b/models/conversations/conversation_summaries.rs new file mode 100644 index 0000000..eccba82 --- /dev/null +++ b/models/conversations/conversation_summaries.rs @@ -0,0 +1,20 @@ +use crate::models::common::{JsonValue, SummaryType}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ConversationSummary { + pub id: Uuid, + pub conversation_id: Uuid, + pub ai_model_id: Option, + pub generated_by: Option, + pub from_message_id: Option, + pub to_message_id: Option, + pub summary_type: SummaryType, + pub content: String, + pub token_count: Option, + pub metadata: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/conversations/conversation_tool_calls.rs b/models/conversations/conversation_tool_calls.rs new file mode 100644 index 0000000..02c4cfa --- /dev/null +++ b/models/conversations/conversation_tool_calls.rs @@ -0,0 +1,22 @@ +use crate::models::common::{JsonValue, Status}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ConversationToolCall { + pub id: Uuid, + pub conversation_id: Uuid, + pub message_id: Uuid, + pub agent_id: Option, + pub tool_name: String, + pub call_id: Option, + pub status: Status, + pub arguments: Option, + pub result: Option, + pub error_message: Option, + pub started_at: Option>, + pub finished_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/conversations/mod.rs b/models/conversations/mod.rs new file mode 100644 index 0000000..8513c9f --- /dev/null +++ b/models/conversations/mod.rs @@ -0,0 +1,15 @@ +pub mod conversation; +pub mod conversation_attachments; +pub mod conversation_bookmarks; +pub mod conversation_messages; +pub mod conversation_participants; +pub mod conversation_summaries; +pub mod conversation_tool_calls; + +pub use conversation::Conversation; +pub use conversation_attachments::ConversationAttachment; +pub use conversation_bookmarks::ConversationBookmark; +pub use conversation_messages::ConversationMessage; +pub use conversation_participants::ConversationParticipant; +pub use conversation_summaries::ConversationSummary; +pub use conversation_tool_calls::ConversationToolCall; diff --git a/models/db.rs b/models/db.rs new file mode 100644 index 0000000..ef66157 --- /dev/null +++ b/models/db.rs @@ -0,0 +1,130 @@ +use crate::config::AppConfig; +use crate::error::AppResult; +use sqlx::PgPool; +use sqlx::postgres::PgPoolOptions; +use std::ops::Deref; +use std::time::Duration; + +pub trait PoolProvider: Clone + Send + Sync + 'static { + fn read(&self) -> &PgPool; + fn write(&self) -> &PgPool; +} + +#[derive(Clone, Debug)] +pub struct AppDatabase { + primary: PgPool, + replica: Option, +} + +impl AppDatabase { + pub fn reader(&self) -> &PgPool { + self.replica.as_ref().unwrap_or(&self.primary) + } + + pub fn writer(&self) -> &PgPool { + &self.primary + } + + pub fn new(primary: PgPool) -> Self { + Self { + primary, + replica: None, + } + } + + pub fn with_replica(primary: PgPool, replica: PgPool) -> Self { + Self { + primary, + replica: Some(replica), + } + } + + pub async fn from_config(config: &AppConfig) -> AppResult { + let primary_url = config.database_url()?; + let primary = Self::build_pool(&primary_url, config, false).await?; + + if config.database_read_write_split()? { + let replicas = config.database_read_replicas()?; + if let Some(replica_url) = replicas.first() { + let replica = Self::build_pool(replica_url, config, true).await?; + return Ok(Self { + primary, + replica: Some(replica), + }); + } + } + + Ok(Self { + primary, + replica: None, + }) + } + + async fn build_pool(url: &str, config: &AppConfig, is_replica: bool) -> AppResult { + let (max_conn, min_conn, idle_timeout, max_lifetime, conn_timeout) = if is_replica { + ( + config.database_replica_max_connections()?, + config.database_replica_min_connections()?, + config.database_replica_idle_timeout()?, + config.database_replica_max_lifetime()?, + config.database_replica_connection_timeout()?, + ) + } else { + ( + config.database_max_connections()?, + config.database_min_connections()?, + config.database_idle_timeout()?, + config.database_max_lifetime()?, + config.database_connection_timeout()?, + ) + }; + + Ok(PgPoolOptions::new() + .max_connections(max_conn) + .min_connections(min_conn) + .idle_timeout(Duration::from_secs(idle_timeout)) + .max_lifetime(Duration::from_secs(max_lifetime)) + .acquire_timeout(Duration::from_secs(conn_timeout)) + .connect(url) + .await?) + } + + pub fn has_replica(&self) -> bool { + self.replica.is_some() + } + + pub async fn close(&self) { + self.primary.close().await; + if let Some(replica) = &self.replica { + replica.close().await; + } + } +} + +impl PoolProvider for AppDatabase { + fn read(&self) -> &PgPool { + self.replica.as_ref().unwrap_or(&self.primary) + } + + fn write(&self) -> &PgPool { + &self.primary + } +} + +impl Deref for AppDatabase { + type Target = PgPool; + + fn deref(&self) -> &Self::Target { + &self.primary + } +} + +impl PoolProvider for PgPool { + fn read(&self) -> &PgPool { + self + } + + fn write(&self) -> &PgPool { + self + } +} diff --git a/models/issues/issue.rs b/models/issues/issue.rs new file mode 100644 index 0000000..ab20d3b --- /dev/null +++ b/models/issues/issue.rs @@ -0,0 +1,25 @@ +use crate::models::common::{Priority, State, Visibility}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct Issue { + pub id: Uuid, + pub workspace_id: Uuid, + pub author_id: Uuid, + pub number: i64, + pub title: String, + pub body: Option, + pub state: State, + pub priority: Priority, + pub visibility: Visibility, + pub locked: bool, + pub milestone_id: Option, + pub closed_by: Option, + pub closed_at: Option>, + pub due_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/models/issues/issue_assignees.rs b/models/issues/issue_assignees.rs new file mode 100644 index 0000000..cc3d477 --- /dev/null +++ b/models/issues/issue_assignees.rs @@ -0,0 +1,12 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct IssueAssignee { + pub id: Uuid, + pub issue_id: Uuid, + pub assignee_id: Uuid, + pub assigned_by: Option, + pub created_at: DateTime, +} diff --git a/models/issues/issue_comments.rs b/models/issues/issue_comments.rs new file mode 100644 index 0000000..a11fd9d --- /dev/null +++ b/models/issues/issue_comments.rs @@ -0,0 +1,16 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct IssueComment { + pub id: Uuid, + pub issue_id: Uuid, + pub author_id: Uuid, + pub body: String, + pub reply_to_comment_id: Option, + pub edited_at: Option>, + pub deleted_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/issues/issue_commit_relations.rs b/models/issues/issue_commit_relations.rs new file mode 100644 index 0000000..df9f0d2 --- /dev/null +++ b/models/issues/issue_commit_relations.rs @@ -0,0 +1,16 @@ +use crate::models::common::RelationType; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct IssueCommitRelation { + pub id: Uuid, + pub issue_id: Uuid, + pub repo_id: Uuid, + pub push_commit_id: Option, + pub commit_sha: String, + pub relation_type: RelationType, + pub created_by: Option, + pub created_at: DateTime, +} diff --git a/models/issues/issue_events.rs b/models/issues/issue_events.rs new file mode 100644 index 0000000..8433384 --- /dev/null +++ b/models/issues/issue_events.rs @@ -0,0 +1,16 @@ +use crate::models::common::{EventType, JsonValue}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct IssueEvent { + pub id: Uuid, + pub issue_id: Uuid, + pub actor_id: Option, + pub event_type: EventType, + pub old_value: Option, + pub new_value: Option, + pub metadata: Option, + pub created_at: DateTime, +} diff --git a/models/issues/issue_label_relations.rs b/models/issues/issue_label_relations.rs new file mode 100644 index 0000000..856074f --- /dev/null +++ b/models/issues/issue_label_relations.rs @@ -0,0 +1,12 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct IssueLabelRelation { + pub id: Uuid, + pub issue_id: Uuid, + pub label_id: Uuid, + pub created_by: Option, + pub created_at: DateTime, +} diff --git a/models/issues/issue_labels.rs b/models/issues/issue_labels.rs new file mode 100644 index 0000000..46db829 --- /dev/null +++ b/models/issues/issue_labels.rs @@ -0,0 +1,15 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct IssueLabel { + pub id: Uuid, + pub repo_id: Uuid, + pub name: String, + pub color: String, + pub description: Option, + pub created_by: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/issues/issue_milestones.rs b/models/issues/issue_milestones.rs new file mode 100644 index 0000000..ba69fef --- /dev/null +++ b/models/issues/issue_milestones.rs @@ -0,0 +1,18 @@ +use crate::models::common::State; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct IssueMilestone { + pub id: Uuid, + pub repo_id: Uuid, + pub title: String, + pub description: Option, + pub state: State, + pub due_at: Option>, + pub closed_at: Option>, + pub created_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/issues/issue_pr_relations.rs b/models/issues/issue_pr_relations.rs new file mode 100644 index 0000000..8807e5d --- /dev/null +++ b/models/issues/issue_pr_relations.rs @@ -0,0 +1,14 @@ +use crate::models::common::RelationType; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct IssuePrRelation { + pub id: Uuid, + pub issue_id: Uuid, + pub pull_request_id: Uuid, + pub relation_type: RelationType, + pub created_by: Option, + pub created_at: DateTime, +} diff --git a/models/issues/issue_queries.rs b/models/issues/issue_queries.rs new file mode 100644 index 0000000..9e19ee8 --- /dev/null +++ b/models/issues/issue_queries.rs @@ -0,0 +1,43 @@ +use sqlx::PgPool; +use uuid::Uuid; + +use super::issue::Issue; + +impl Issue { + pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, Issue>( + "SELECT id, workspace_id, author_id, number, title, body, state, priority, visibility, \ + locked, milestone_id, closed_by, closed_at, due_at, created_at, updated_at, deleted_at \ + FROM issue WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(id) + .fetch_optional(pool) + .await + } + + pub async fn find_by_number( + pool: &PgPool, + workspace_id: Uuid, + number: i64, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, Issue>( + "SELECT id, workspace_id, author_id, number, title, body, state, priority, visibility, \ + locked, milestone_id, closed_by, closed_at, due_at, created_at, updated_at, deleted_at \ + FROM issue WHERE workspace_id = $1 AND number = $2 AND deleted_at IS NULL", + ) + .bind(workspace_id) + .bind(number) + .fetch_optional(pool) + .await + } + + pub async fn next_number<'e, E>(executor: E, workspace_id: Uuid) -> Result + where + E: sqlx::PgExecutor<'e>, + { + sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM issue WHERE workspace_id = $1") + .bind(workspace_id) + .fetch_one(executor) + .await + } +} diff --git a/models/issues/issue_reaction.rs b/models/issues/issue_reaction.rs new file mode 100644 index 0000000..3684d7f --- /dev/null +++ b/models/issues/issue_reaction.rs @@ -0,0 +1,15 @@ +use crate::models::common::TargetType; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct IssueReaction { + pub id: Uuid, + pub issue_id: Uuid, + pub user_id: Uuid, + pub content: String, + pub target_type: TargetType, + pub target_id: Option, + pub created_at: DateTime, +} diff --git a/models/issues/issue_reminder.rs b/models/issues/issue_reminder.rs new file mode 100644 index 0000000..370b767 --- /dev/null +++ b/models/issues/issue_reminder.rs @@ -0,0 +1,16 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct IssueReminder { + pub id: Uuid, + pub issue_id: Uuid, + pub user_id: Uuid, + pub remind_at: DateTime, + pub message: Option, + pub sent_at: Option>, + pub canceled_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/issues/issue_repo_relations.rs b/models/issues/issue_repo_relations.rs new file mode 100644 index 0000000..eaf865f --- /dev/null +++ b/models/issues/issue_repo_relations.rs @@ -0,0 +1,14 @@ +use crate::models::common::RelationType; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct IssueRepoRelation { + pub id: Uuid, + pub issue_id: Uuid, + pub repo_id: Uuid, + pub relation_type: RelationType, + pub created_by: Option, + pub created_at: DateTime, +} diff --git a/models/issues/issue_stats.rs b/models/issues/issue_stats.rs new file mode 100644 index 0000000..71d70a5 --- /dev/null +++ b/models/issues/issue_stats.rs @@ -0,0 +1,15 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct IssueStats { + pub issue_id: Uuid, + pub comments_count: i64, + pub reactions_count: i64, + pub assignees_count: i64, + pub labels_count: i64, + pub subscribers_count: i64, + pub last_commented_at: Option>, + pub updated_at: DateTime, +} diff --git a/models/issues/issue_subscribers.rs b/models/issues/issue_subscribers.rs new file mode 100644 index 0000000..f066756 --- /dev/null +++ b/models/issues/issue_subscribers.rs @@ -0,0 +1,14 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct IssueSubscriber { + pub id: Uuid, + pub issue_id: Uuid, + pub user_id: Uuid, + pub reason: String, + pub muted: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/issues/issue_templates.rs b/models/issues/issue_templates.rs new file mode 100644 index 0000000..c4b72e1 --- /dev/null +++ b/models/issues/issue_templates.rs @@ -0,0 +1,18 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct IssueTemplate { + pub id: Uuid, + pub repo_id: Uuid, + pub name: String, + pub description: Option, + pub title_template: Option, + pub body_template: String, + pub labels: Vec, + pub active: bool, + pub created_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/issues/mod.rs b/models/issues/mod.rs new file mode 100644 index 0000000..e799d92 --- /dev/null +++ b/models/issues/mod.rs @@ -0,0 +1,32 @@ +pub mod issue; +pub mod issue_assignees; +pub mod issue_comments; +pub mod issue_commit_relations; +pub mod issue_events; +pub mod issue_label_relations; +pub mod issue_labels; +pub mod issue_milestones; +pub mod issue_pr_relations; +pub mod issue_queries; +pub mod issue_reaction; +pub mod issue_reminder; +pub mod issue_repo_relations; +pub mod issue_stats; +pub mod issue_subscribers; +pub mod issue_templates; + +pub use issue::Issue; +pub use issue_assignees::IssueAssignee; +pub use issue_comments::IssueComment; +pub use issue_commit_relations::IssueCommitRelation; +pub use issue_events::IssueEvent; +pub use issue_label_relations::IssueLabelRelation; +pub use issue_labels::IssueLabel; +pub use issue_milestones::IssueMilestone; +pub use issue_pr_relations::IssuePrRelation; +pub use issue_reaction::IssueReaction; +pub use issue_reminder::IssueReminder; +pub use issue_repo_relations::IssueRepoRelation; +pub use issue_stats::IssueStats; +pub use issue_subscribers::IssueSubscriber; +pub use issue_templates::IssueTemplate; diff --git a/models/json_types.rs b/models/json_types.rs new file mode 100644 index 0000000..f7d4964 --- /dev/null +++ b/models/json_types.rs @@ -0,0 +1,94 @@ +use crate::models::common::{ + AiFeature, EventType, JsonValue, Modality, Permission, Priority, Scope, Status, TargetType, +}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use uuid::Uuid; + +pub type TypedJson = sqlx::types::Json; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ModelParameters { + pub temperature: Option, + pub top_p: Option, + pub max_tokens: Option, + pub stop_sequences: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct RetryPolicy { + pub max_attempts: Option, + pub initial_backoff_ms: Option, + pub max_backoff_ms: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct AgentVersionConfig { + pub parameters: Option, + pub retry: Option, + pub timeout_seconds: Option, + pub parallel_tool_calls: Option, + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct AgentEventFilters { + pub event_actions: Vec, + pub branch_patterns: Vec, + pub path_patterns: Vec, + pub label_names: Vec, + pub actor_ids: Vec, + pub include_bots: Option, + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct AgentSchedulePayload { + pub target_type: Option, + pub target_id: Option, + pub variables: BTreeMap, + pub dry_run: Option, + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct AiModelCapabilityConfig { + pub input_modalities: Vec, + pub output_modalities: Vec, + pub supported_features: Vec, + pub max_input_tokens: Option, + pub max_output_tokens: Option, + pub limits: BTreeMap, + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)] +pub struct WorkspaceIntegrationConfig { + pub scopes: Vec, + pub permissions: Vec, + pub repo_ids: Vec, + pub channel_ids: Vec, + pub callback_url: Option, + pub settings: BTreeMap, + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)] +pub struct NotificationMetadata { + pub source: Option, + pub severity: Option, + pub dedupe_key: Option, + pub template_data: BTreeMap, + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ConversationAttachmentMetadata { + pub checksum_sha256: Option, + pub width: Option, + pub height: Option, + pub duration_ms: Option, + pub preview_url: Option, + pub virus_scan_status: Option, + pub extra: BTreeMap, +} diff --git a/models/mod.rs b/models/mod.rs new file mode 100644 index 0000000..fc236dd --- /dev/null +++ b/models/mod.rs @@ -0,0 +1,14 @@ +pub mod agents; +pub mod ais; +pub mod channels; +pub mod common; +pub mod conversations; +pub mod db; +pub mod issues; +pub mod json_types; +pub mod notifications; +pub mod prs; +pub mod repos; +pub mod users; +pub mod wiki; +pub mod workspaces; diff --git a/models/notifications/mod.rs b/models/notifications/mod.rs new file mode 100644 index 0000000..bfec37b --- /dev/null +++ b/models/notifications/mod.rs @@ -0,0 +1,11 @@ +pub mod notification; +pub mod notification_blocks; +pub mod notification_deliveries; +pub mod notification_subscriptions; +pub mod notification_templates; + +pub use notification::Notification; +pub use notification_blocks::NotificationBlock; +pub use notification_deliveries::NotificationDelivery; +pub use notification_subscriptions::NotificationSubscription; +pub use notification_templates::NotificationTemplate; diff --git a/models/notifications/notification.rs b/models/notifications/notification.rs new file mode 100644 index 0000000..7281779 --- /dev/null +++ b/models/notifications/notification.rs @@ -0,0 +1,31 @@ +use crate::models::common::{NotificationType, Priority, TargetType}; +use crate::models::json_types::{NotificationMetadata, TypedJson}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct Notification { + pub id: Uuid, + pub user_id: Uuid, + pub actor_id: Option, + pub workspace_id: Option, + pub repo_id: Option, + pub issue_id: Option, + pub pull_request_id: Option, + pub channel_id: Option, + pub message_id: Option, + pub notification_type: NotificationType, + pub title: String, + pub body: Option, + pub target_type: Option, + pub target_id: Option, + pub action_url: Option, + pub priority: Priority, + pub read_at: Option>, + pub dismissed_at: Option>, + pub metadata: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/models/notifications/notification_blocks.rs b/models/notifications/notification_blocks.rs new file mode 100644 index 0000000..36e7863 --- /dev/null +++ b/models/notifications/notification_blocks.rs @@ -0,0 +1,20 @@ +use crate::models::common::{DeliveryChannel, NotificationType, TargetType}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct NotificationBlock { + pub id: Uuid, + pub user_id: Uuid, + pub workspace_id: Option, + pub repo_id: Option, + pub target_type: TargetType, + pub target_id: Option, + pub notification_type: Option, + pub channel: Option, + pub reason: Option, + pub expires_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/notifications/notification_deliveries.rs b/models/notifications/notification_deliveries.rs new file mode 100644 index 0000000..a6dbfac --- /dev/null +++ b/models/notifications/notification_deliveries.rs @@ -0,0 +1,24 @@ +use crate::models::common::{DeliveryChannel, Provider, Status}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct NotificationDelivery { + pub id: Uuid, + pub notification_id: Uuid, + pub user_id: Uuid, + pub channel: DeliveryChannel, + pub destination: Option, + pub status: Status, + pub provider: Option, + pub provider_message_id: Option, + pub attempts: i32, + pub last_error: Option, + pub scheduled_at: Option>, + pub sent_at: Option>, + pub delivered_at: Option>, + pub failed_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/notifications/notification_subscriptions.rs b/models/notifications/notification_subscriptions.rs new file mode 100644 index 0000000..7d019f4 --- /dev/null +++ b/models/notifications/notification_subscriptions.rs @@ -0,0 +1,21 @@ +use crate::models::common::{EventType, SubscriptionLevel, TargetType}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct NotificationSubscription { + pub id: Uuid, + pub user_id: Uuid, + pub workspace_id: Option, + pub repo_id: Option, + pub target_type: TargetType, + pub target_id: Option, + pub event_types: Vec, + pub channels: Vec, + pub level: SubscriptionLevel, + pub muted: bool, + pub muted_until: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/notifications/notification_templates.rs b/models/notifications/notification_templates.rs new file mode 100644 index 0000000..98c2471 --- /dev/null +++ b/models/notifications/notification_templates.rs @@ -0,0 +1,21 @@ +use crate::models::common::{DeliveryChannel, NotificationType}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct NotificationTemplate { + pub id: Uuid, + pub key: String, + pub notification_type: NotificationType, + pub channel: DeliveryChannel, + pub locale: String, + pub subject_template: Option, + pub title_template: String, + pub body_template: String, + pub action_text_template: Option, + pub enabled: bool, + pub created_by: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/prs/mod.rs b/models/prs/mod.rs new file mode 100644 index 0000000..7ab8f16 --- /dev/null +++ b/models/prs/mod.rs @@ -0,0 +1,30 @@ +pub mod pr_assignees; +pub mod pr_check_runs; +pub mod pr_commits; +pub mod pr_events; +pub mod pr_files; +pub mod pr_label_relations; +pub mod pr_labels; +pub mod pr_merge_strategy; +pub mod pr_reactions; +pub mod pr_review; +pub mod pr_review_comment; +pub mod pr_status; +pub mod pr_subscriptions; +pub mod pull_request; +pub mod pull_request_queries; + +pub use pr_assignees::PrAssignee; +pub use pr_check_runs::PrCheckRun; +pub use pr_commits::PrCommit; +pub use pr_events::PrEvent; +pub use pr_files::PrFile; +pub use pr_label_relations::PrLabelRelation; +pub use pr_labels::PrLabel; +pub use pr_merge_strategy::PrMergeStrategy; +pub use pr_reactions::PrReaction; +pub use pr_review::PrReview; +pub use pr_review_comment::PrReviewComment; +pub use pr_status::PrStatus; +pub use pr_subscriptions::PrSubscription; +pub use pull_request::PullRequest; diff --git a/models/prs/pr_assignees.rs b/models/prs/pr_assignees.rs new file mode 100644 index 0000000..3f08e06 --- /dev/null +++ b/models/prs/pr_assignees.rs @@ -0,0 +1,12 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct PrAssignee { + pub id: Uuid, + pub pull_request_id: Uuid, + pub assignee_id: Uuid, + pub assigned_by: Option, + pub created_at: DateTime, +} diff --git a/models/prs/pr_check_runs.rs b/models/prs/pr_check_runs.rs new file mode 100644 index 0000000..08715ea --- /dev/null +++ b/models/prs/pr_check_runs.rs @@ -0,0 +1,20 @@ +use crate::models::common::Status; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct PrCheckRun { + pub id: Uuid, + pub pull_request_id: Uuid, + pub commit_sha: String, + pub name: String, + pub status: Status, + pub conclusion: Option, + pub details_url: Option, + pub external_id: Option, + pub started_at: Option>, + pub completed_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/prs/pr_commits.rs b/models/prs/pr_commits.rs new file mode 100644 index 0000000..568a9c1 --- /dev/null +++ b/models/prs/pr_commits.rs @@ -0,0 +1,15 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct PrCommit { + pub id: Uuid, + pub pull_request_id: Uuid, + pub repo_id: Uuid, + pub commit_sha: String, + pub position: i32, + pub authored_at: Option>, + pub committed_at: Option>, + pub created_at: DateTime, +} diff --git a/models/prs/pr_events.rs b/models/prs/pr_events.rs new file mode 100644 index 0000000..e1b2fb8 --- /dev/null +++ b/models/prs/pr_events.rs @@ -0,0 +1,16 @@ +use crate::models::common::{EventType, JsonValue}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct PrEvent { + pub id: Uuid, + pub pull_request_id: Uuid, + pub actor_id: Option, + pub event_type: EventType, + pub old_value: Option, + pub new_value: Option, + pub metadata: Option, + pub created_at: DateTime, +} diff --git a/models/prs/pr_files.rs b/models/prs/pr_files.rs new file mode 100644 index 0000000..5657c70 --- /dev/null +++ b/models/prs/pr_files.rs @@ -0,0 +1,19 @@ +use crate::models::common::Status; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct PrFile { + pub id: Uuid, + pub pull_request_id: Uuid, + pub path: String, + pub old_path: Option, + pub status: Status, + pub additions: i32, + pub deletions: i32, + pub changes: i32, + pub patch: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/prs/pr_label_relations.rs b/models/prs/pr_label_relations.rs new file mode 100644 index 0000000..51706c9 --- /dev/null +++ b/models/prs/pr_label_relations.rs @@ -0,0 +1,12 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct PrLabelRelation { + pub id: Uuid, + pub pull_request_id: Uuid, + pub label_id: Uuid, + pub created_by: Option, + pub created_at: DateTime, +} diff --git a/models/prs/pr_labels.rs b/models/prs/pr_labels.rs new file mode 100644 index 0000000..1486ea9 --- /dev/null +++ b/models/prs/pr_labels.rs @@ -0,0 +1,15 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct PrLabel { + pub id: Uuid, + pub repo_id: Uuid, + pub name: String, + pub color: String, + pub description: Option, + pub created_by: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/prs/pr_merge_strategy.rs b/models/prs/pr_merge_strategy.rs new file mode 100644 index 0000000..b0e4273 --- /dev/null +++ b/models/prs/pr_merge_strategy.rs @@ -0,0 +1,18 @@ +use crate::models::common::MergeStrategyKind; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct PrMergeStrategy { + pub pull_request_id: Uuid, + pub strategy: MergeStrategyKind, + pub auto_merge: bool, + pub squash_title: Option, + pub squash_message: Option, + pub delete_source_branch: bool, + pub merge_when_checks_pass: bool, + pub selected_by: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/prs/pr_reactions.rs b/models/prs/pr_reactions.rs new file mode 100644 index 0000000..49b952c --- /dev/null +++ b/models/prs/pr_reactions.rs @@ -0,0 +1,15 @@ +use crate::models::common::TargetType; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct PrReaction { + pub id: Uuid, + pub pull_request_id: Uuid, + pub user_id: Uuid, + pub content: String, + pub target_type: TargetType, + pub target_id: Option, + pub created_at: DateTime, +} diff --git a/models/prs/pr_review.rs b/models/prs/pr_review.rs new file mode 100644 index 0000000..5257f47 --- /dev/null +++ b/models/prs/pr_review.rs @@ -0,0 +1,19 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct PrReview { + pub id: Uuid, + pub pull_request_id: Uuid, + pub author_id: Uuid, + pub state: String, + pub body: Option, + pub commit_sha: Option, + pub submitted_at: Option>, + pub dismissed_at: Option>, + pub dismissed_by: Option, + pub dismiss_reason: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/prs/pr_review_comment.rs b/models/prs/pr_review_comment.rs new file mode 100644 index 0000000..3573b86 --- /dev/null +++ b/models/prs/pr_review_comment.rs @@ -0,0 +1,22 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct PrReviewComment { + pub id: Uuid, + pub review_id: Uuid, + pub pull_request_id: Uuid, + pub author_id: Uuid, + pub body: String, + pub path: String, + pub line: Option, + pub original_line: Option, + pub start_line: Option, + pub original_start_line: Option, + pub diff_hunk: Option, + pub in_reply_to_id: Option, + pub edited_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/prs/pr_status.rs b/models/prs/pr_status.rs new file mode 100644 index 0000000..797d983 --- /dev/null +++ b/models/prs/pr_status.rs @@ -0,0 +1,19 @@ +use crate::models::common::State; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct PrStatus { + pub pull_request_id: Uuid, + pub head_commit_sha: String, + pub checks_state: State, + pub mergeable_state: State, + pub conflicts: bool, + pub approvals_count: i32, + pub requested_reviews_count: i32, + pub changed_files_count: i32, + pub additions_count: i32, + pub deletions_count: i32, + pub updated_at: DateTime, +} diff --git a/models/prs/pr_subscriptions.rs b/models/prs/pr_subscriptions.rs new file mode 100644 index 0000000..f215f63 --- /dev/null +++ b/models/prs/pr_subscriptions.rs @@ -0,0 +1,14 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct PrSubscription { + pub id: Uuid, + pub pull_request_id: Uuid, + pub user_id: Uuid, + pub reason: String, + pub muted: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/prs/pull_request.rs b/models/prs/pull_request.rs new file mode 100644 index 0000000..577433f --- /dev/null +++ b/models/prs/pull_request.rs @@ -0,0 +1,31 @@ +use crate::models::common::State; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct PullRequest { + pub id: Uuid, + pub repo_id: Uuid, + pub author_id: Uuid, + pub number: i64, + pub title: String, + pub body: Option, + pub state: State, + pub source_repo_id: Uuid, + pub source_branch: String, + pub target_repo_id: Uuid, + pub target_branch: String, + pub base_commit_sha: Option, + pub head_commit_sha: String, + pub merge_commit_sha: Option, + pub draft: bool, + pub locked: bool, + pub merged_by: Option, + pub merged_at: Option>, + pub closed_by: Option, + pub closed_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/models/prs/pull_request_queries.rs b/models/prs/pull_request_queries.rs new file mode 100644 index 0000000..9490bd4 --- /dev/null +++ b/models/prs/pull_request_queries.rs @@ -0,0 +1,49 @@ +use sqlx::PgPool; +use uuid::Uuid; + +use super::pull_request::PullRequest; + +impl PullRequest { + pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, PullRequest>( + "SELECT id, repo_id, author_id, number, title, body, state, source_repo_id, \ + source_branch, target_repo_id, target_branch, base_commit_sha, head_commit_sha, \ + merge_commit_sha, draft, locked, merged_by, merged_at, closed_by, closed_at, \ + created_at, updated_at, deleted_at \ + FROM pull_request WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(id) + .fetch_optional(pool) + .await + } + + pub async fn find_by_number( + pool: &PgPool, + repo_id: Uuid, + number: i64, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, PullRequest>( + "SELECT id, repo_id, author_id, number, title, body, state, source_repo_id, \ + source_branch, target_repo_id, target_branch, base_commit_sha, head_commit_sha, \ + merge_commit_sha, draft, locked, merged_by, merged_at, closed_by, closed_at, \ + created_at, updated_at, deleted_at \ + FROM pull_request WHERE repo_id = $1 AND number = $2 AND deleted_at IS NULL", + ) + .bind(repo_id) + .bind(number) + .fetch_optional(pool) + .await + } + + pub async fn next_number<'e, E>(executor: E, repo_id: Uuid) -> Result + where + E: sqlx::PgExecutor<'e>, + { + sqlx::query_scalar( + "SELECT COALESCE(MAX(number), 0) + 1 FROM pull_request WHERE repo_id = $1", + ) + .bind(repo_id) + .fetch_one(executor) + .await + } +} diff --git a/models/repos/branch_protection_rule.rs b/models/repos/branch_protection_rule.rs new file mode 100644 index 0000000..9df3320 --- /dev/null +++ b/models/repos/branch_protection_rule.rs @@ -0,0 +1,27 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct BranchProtectionRule { + pub id: Uuid, + pub repo_id: Uuid, + pub pattern: String, + pub require_approvals: i32, + pub require_status_checks: bool, + pub required_status_checks: Vec, + pub require_linear_history: bool, + pub allow_force_pushes: bool, + pub allow_deletions: bool, + pub require_signed_commits: bool, + pub require_code_owner_review: bool, + pub dismiss_stale_reviews: bool, + pub restrict_pushes: bool, + pub push_allowances: Vec, + pub restrict_review_dismissal: bool, + pub dismissal_allowances: Vec, + pub require_conversation_resolution: bool, + pub created_by: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/repos/mod.rs b/models/repos/mod.rs new file mode 100644 index 0000000..64bfa66 --- /dev/null +++ b/models/repos/mod.rs @@ -0,0 +1,36 @@ +pub mod branch_protection_rule; +pub mod repo; +pub mod repo_branches; +pub mod repo_commit_comments; +pub mod repo_commit_statuses; +pub mod repo_deploy_keys; +pub mod repo_fork; +pub mod repo_invitations; +pub mod repo_members; +pub mod repo_push_commit; +pub mod repo_push_lock; +pub mod repo_queries; +pub mod repo_releases; +pub mod repo_stars; +pub mod repo_stats; +pub mod repo_tags; +pub mod repo_watches; +pub mod repo_webhooks; + +pub use branch_protection_rule::BranchProtectionRule; +pub use repo::Repo; +pub use repo_branches::RepoBranch; +pub use repo_commit_comments::RepoCommitComment; +pub use repo_commit_statuses::RepoCommitStatus; +pub use repo_deploy_keys::RepoDeployKey; +pub use repo_fork::RepoFork; +pub use repo_invitations::RepoInvitation; +pub use repo_members::RepoMember; +pub use repo_push_commit::RepoPushCommit; +pub use repo_push_lock::RepoPushLock; +pub use repo_releases::RepoRelease; +pub use repo_stars::RepoStar; +pub use repo_stats::RepoStats; +pub use repo_tags::RepoTag; +pub use repo_watches::RepoWatch; +pub use repo_webhooks::RepoWebhook; diff --git a/models/repos/repo.rs b/models/repos/repo.rs new file mode 100644 index 0000000..4123f0e --- /dev/null +++ b/models/repos/repo.rs @@ -0,0 +1,26 @@ +use crate::models::common::{GitService, Status, Visibility}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct Repo { + pub id: Uuid, + pub workspace_id: Uuid, + pub owner_id: Uuid, + pub name: String, + pub description: Option, + pub default_branch: String, + pub visibility: Visibility, + pub status: Status, + pub is_fork: bool, + pub forked_from_repo_id: Option, + pub storage_node_ids: Vec, + pub primary_storage_node_id: Uuid, + pub storage_path: String, + pub git_service: GitService, + pub archived_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/models/repos/repo_branches.rs b/models/repos/repo_branches.rs new file mode 100644 index 0000000..7dff5b4 --- /dev/null +++ b/models/repos/repo_branches.rs @@ -0,0 +1,18 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct RepoBranch { + pub id: Uuid, + pub repo_id: Uuid, + pub name: String, + pub commit_sha: String, + pub protected: bool, + pub default_branch: bool, + pub created_by: Option, + pub last_push_id: Option, + pub last_push_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/repos/repo_commit_comments.rs b/models/repos/repo_commit_comments.rs new file mode 100644 index 0000000..6739af6 --- /dev/null +++ b/models/repos/repo_commit_comments.rs @@ -0,0 +1,21 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct RepoCommitComment { + pub id: Uuid, + pub repo_id: Uuid, + pub push_commit_id: Uuid, + pub commit_sha: String, + pub author_id: Uuid, + pub body: String, + pub path: Option, + pub line: Option, + pub resolved: bool, + pub resolved_by: Option, + pub resolved_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/models/repos/repo_commit_statuses.rs b/models/repos/repo_commit_statuses.rs new file mode 100644 index 0000000..d21bfa7 --- /dev/null +++ b/models/repos/repo_commit_statuses.rs @@ -0,0 +1,20 @@ +use crate::models::common::State; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct RepoCommitStatus { + pub id: Uuid, + pub repo_id: Uuid, + pub push_commit_id: Uuid, + pub latest_commit_sha: String, + pub context: String, + pub state: State, + pub target_url: Option, + pub description: Option, + pub reported_by: Option, + pub reported_at: DateTime, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/repos/repo_deploy_keys.rs b/models/repos/repo_deploy_keys.rs new file mode 100644 index 0000000..6e8c5e2 --- /dev/null +++ b/models/repos/repo_deploy_keys.rs @@ -0,0 +1,21 @@ +use crate::models::common::KeyType; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct RepoDeployKey { + pub id: Uuid, + pub repo_id: Uuid, + pub title: String, + pub public_key: String, + pub fingerprint_sha256: String, + pub key_type: KeyType, + pub read_only: bool, + pub last_used_at: Option>, + pub expires_at: Option>, + pub revoked_at: Option>, + pub created_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/repos/repo_fork.rs b/models/repos/repo_fork.rs new file mode 100644 index 0000000..56ce122 --- /dev/null +++ b/models/repos/repo_fork.rs @@ -0,0 +1,12 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct RepoFork { + pub id: Uuid, + pub parent_repo_id: Uuid, + pub fork_repo_id: Uuid, + pub forked_by: Uuid, + pub created_at: DateTime, +} diff --git a/models/repos/repo_invitations.rs b/models/repos/repo_invitations.rs new file mode 100644 index 0000000..52c49db --- /dev/null +++ b/models/repos/repo_invitations.rs @@ -0,0 +1,19 @@ +use crate::models::common::Role; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct RepoInvitation { + pub id: Uuid, + pub repo_id: Uuid, + pub email: String, + pub role: Role, + pub token_hash: String, + pub invited_by: Uuid, + pub accepted_by: Option, + pub accepted_at: Option>, + pub revoked_at: Option>, + pub expires_at: DateTime, + pub created_at: DateTime, +} diff --git a/models/repos/repo_members.rs b/models/repos/repo_members.rs new file mode 100644 index 0000000..3b51543 --- /dev/null +++ b/models/repos/repo_members.rs @@ -0,0 +1,18 @@ +use crate::models::common::{Role, Status}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct RepoMember { + pub id: Uuid, + pub repo_id: Uuid, + pub user_id: Uuid, + pub role: Role, + pub status: Status, + pub invited_by: Option, + pub joined_at: Option>, + pub last_active_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/repos/repo_push_commit.rs b/models/repos/repo_push_commit.rs new file mode 100644 index 0000000..e9db0d7 --- /dev/null +++ b/models/repos/repo_push_commit.rs @@ -0,0 +1,18 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct RepoPushCommit { + pub id: Uuid, + pub repo_id: Uuid, + pub pusher_id: Uuid, + pub branch_name: String, + pub old_commit_sha: Option, + pub latest_commit_sha: String, + pub commit_shas: Vec, + pub commit_count: i32, + pub push_status: String, + pub pushed_at: DateTime, + pub created_at: DateTime, +} diff --git a/models/repos/repo_push_lock.rs b/models/repos/repo_push_lock.rs new file mode 100644 index 0000000..8d41a3d --- /dev/null +++ b/models/repos/repo_push_lock.rs @@ -0,0 +1,22 @@ +use crate::models::common::Status; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct RepoPushLock { + pub id: Uuid, + pub repo_id: Uuid, + pub pusher_id: Uuid, + pub ref_name: String, + pub status: Status, + pub queue_position: i32, + pub queued_at: DateTime, + pub started_at: Option>, + pub finished_at: Option>, + pub storage_node_id: Option, + pub lease_token: Option, + pub error_message: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/repos/repo_queries.rs b/models/repos/repo_queries.rs new file mode 100644 index 0000000..340f937 --- /dev/null +++ b/models/repos/repo_queries.rs @@ -0,0 +1,105 @@ +//! SQL query methods for the `Repo` entity. + +use sqlx::PgPool; +use uuid::Uuid; + +use super::repo::Repo; +use crate::models::common::{Role, Visibility}; + +impl Repo { + /// Find a non-deleted repo by primary key. + pub async fn find_by_name( + pool: &PgPool, + workspace_id: Uuid, + name: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, Repo>( + "SELECT id, workspace_id, owner_id, name, description, default_branch, visibility, \ + status, is_fork, forked_from_repo_id, storage_node_ids, \ + primary_storage_node_id, storage_path, git_service, \ + archived_at, created_at, updated_at, deleted_at \ + FROM repo WHERE workspace_id = $1 AND name = $2 AND deleted_at IS NULL", + ) + .bind(workspace_id) + .bind(name) + .fetch_optional(pool) + .await + } + + pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, Repo>( + r#"SELECT id, workspace_id, owner_id, name, description, default_branch, visibility, + status, is_fork, forked_from_repo_id, storage_node_ids, + primary_storage_node_id, storage_path, git_service, + archived_at, created_at, updated_at, deleted_at + FROM repo WHERE id = $1 AND deleted_at IS NULL"#, + ) + .bind(id) + .fetch_optional(pool) + .await + } + + /// Check if a user is an active member of a repo. + pub async fn is_member( + pool: &PgPool, + repo_id: Uuid, + user_id: Uuid, + ) -> Result { + sqlx::query_scalar::<_, bool>( + r#"SELECT EXISTS( + SELECT 1 FROM repo_member + WHERE repo_id = $1 AND user_id = $2 AND status = 'active' + )"#, + ) + .bind(repo_id) + .bind(user_id) + .fetch_one(pool) + .await + } + + /// Get the role of a user in a repo. + /// Returns `Role::Owner` if the user is the repo owner but has no explicit member row. + pub async fn user_role( + pool: &PgPool, + repo_id: Uuid, + user_id: Uuid, + owner_id: Uuid, + ) -> Result, sqlx::Error> { + if owner_id == user_id { + return Ok(Some(Role::Owner)); + } + let role_str: Option = sqlx::query_scalar( + r#"SELECT role FROM repo_member + WHERE repo_id = $1 AND user_id = $2 AND status = 'active'"#, + ) + .bind(repo_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + Ok(role_str.and_then(|r| r.parse::().ok())) + } + + /// Check if the user can read the repo. + /// Readable based on visibility + workspace membership + repo membership. + pub async fn is_readable( + pool: &PgPool, + repo: &Repo, + user_id: Uuid, + ) -> Result { + use crate::models::workspaces::Workspace; + + if repo.owner_id == user_id { + return Ok(true); + } + let is_ws_member = Workspace::is_member(pool, repo.workspace_id, user_id).await?; + let is_repo_member = Self::is_member(pool, repo.id, user_id).await?; + + Ok(match repo.visibility { + Visibility::Public => true, + Visibility::Internal => is_ws_member, + Visibility::Private => is_ws_member && is_repo_member, + _ => false, + }) + } +} diff --git a/models/repos/repo_releases.rs b/models/repos/repo_releases.rs new file mode 100644 index 0000000..e770a04 --- /dev/null +++ b/models/repos/repo_releases.rs @@ -0,0 +1,20 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct RepoRelease { + pub id: Uuid, + pub repo_id: Uuid, + pub tag_id: Option, + pub tag_name: String, + pub title: String, + pub body: Option, + pub draft: bool, + pub prerelease: bool, + pub author_id: Uuid, + pub published_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/models/repos/repo_stars.rs b/models/repos/repo_stars.rs new file mode 100644 index 0000000..e7f9f78 --- /dev/null +++ b/models/repos/repo_stars.rs @@ -0,0 +1,11 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct RepoStar { + pub id: Uuid, + pub repo_id: Uuid, + pub user_id: Uuid, + pub created_at: DateTime, +} diff --git a/models/repos/repo_stats.rs b/models/repos/repo_stats.rs new file mode 100644 index 0000000..f5f218b --- /dev/null +++ b/models/repos/repo_stats.rs @@ -0,0 +1,20 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct RepoStats { + pub repo_id: Uuid, + pub stars_count: i64, + pub watchers_count: i64, + pub forks_count: i64, + pub branches_count: i64, + pub tags_count: i64, + pub commits_count: i64, + pub releases_count: i64, + pub open_issues_count: i64, + pub open_pull_requests_count: i64, + pub size_bytes: i64, + pub last_push_at: Option>, + pub updated_at: DateTime, +} diff --git a/models/repos/repo_tags.rs b/models/repos/repo_tags.rs new file mode 100644 index 0000000..ea8dae3 --- /dev/null +++ b/models/repos/repo_tags.rs @@ -0,0 +1,15 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct RepoTag { + pub id: Uuid, + pub repo_id: Uuid, + pub name: String, + pub target_commit_sha: String, + pub tagger_id: Option, + pub message: Option, + pub signed: bool, + pub created_at: DateTime, +} diff --git a/models/repos/repo_watches.rs b/models/repos/repo_watches.rs new file mode 100644 index 0000000..7c72ce8 --- /dev/null +++ b/models/repos/repo_watches.rs @@ -0,0 +1,14 @@ +use crate::models::common::SubscriptionLevel; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct RepoWatch { + pub id: Uuid, + pub repo_id: Uuid, + pub user_id: Uuid, + pub level: SubscriptionLevel, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/repos/repo_webhooks.rs b/models/repos/repo_webhooks.rs new file mode 100644 index 0000000..9a2101e --- /dev/null +++ b/models/repos/repo_webhooks.rs @@ -0,0 +1,19 @@ +use crate::models::common::EventType; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct RepoWebhook { + pub id: Uuid, + pub repo_id: Uuid, + pub url: String, + pub secret_ciphertext: Option, + pub events: Vec, + pub active: bool, + pub last_delivery_status: Option, + pub last_delivery_at: Option>, + pub created_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/users/mod.rs b/models/users/mod.rs new file mode 100644 index 0000000..8294baf --- /dev/null +++ b/models/users/mod.rs @@ -0,0 +1,40 @@ +pub mod user; +pub mod user_2fa; +pub mod user_activity; +pub mod user_appearance; +pub mod user_block; +pub mod user_device; +pub mod user_follow; +pub mod user_gpg_key; +pub mod user_mail; +pub mod user_notify_setting; +pub mod user_oauth; +pub mod user_password; +pub mod user_password_reset; +pub mod user_personal_access_token; +pub mod user_presence; +pub mod user_profile; +pub mod user_queries; +pub mod user_security_log; +pub mod user_session; +pub mod user_ssh_key; + +pub use user::User; +pub use user_2fa::User2Fa; +pub use user_activity::UserActivity; +pub use user_appearance::UserAppearance; +pub use user_block::UserBlock; +pub use user_device::UserDevice; +pub use user_follow::UserFollow; +pub use user_gpg_key::UserGpgKey; +pub use user_mail::UserMail; +pub use user_notify_setting::UserNotifySetting; +pub use user_oauth::UserOAuth; +pub use user_password::UserPassword; +pub use user_password_reset::UserPasswordReset; +pub use user_personal_access_token::UserPersonalAccessToken; +pub use user_presence::UserPresence; +pub use user_profile::UserProfile; +pub use user_security_log::UserSecurityLog; +pub use user_session::UserSession; +pub use user_ssh_key::UserSshKey; diff --git a/models/users/user.rs b/models/users/user.rs new file mode 100644 index 0000000..97f0a2b --- /dev/null +++ b/models/users/user.rs @@ -0,0 +1,22 @@ +use crate::models::common::{Role, Status, Visibility}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct User { + pub id: Uuid, + pub username: String, + pub display_name: Option, + pub avatar_url: Option, + pub bio: Option, + pub status: Status, + pub role: Role, + pub visibility: Visibility, + pub is_active: bool, + pub is_bot: bool, + pub last_login_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/models/users/user_2fa.rs b/models/users/user_2fa.rs new file mode 100644 index 0000000..f0948d7 --- /dev/null +++ b/models/users/user_2fa.rs @@ -0,0 +1,13 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct User2Fa { + pub user_id: Uuid, + pub secret: Option, + pub backup_codes: String, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/users/user_activity.rs b/models/users/user_activity.rs new file mode 100644 index 0000000..88fff7e --- /dev/null +++ b/models/users/user_activity.rs @@ -0,0 +1,25 @@ +use crate::models::common::{ActivityType, JsonValue}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserActivity { + pub id: Uuid, + pub user_id: Uuid, + pub activity_type: ActivityType, + pub name: String, + pub details: Option, + pub state: Option, + pub application_id: Option, + pub assets: Option, + pub party_id: Option, + pub party_current_size: Option, + pub party_max_size: Option, + pub large_image_url: Option, + pub small_image_url: Option, + pub start_at: Option>, + pub end_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/users/user_appearance.rs b/models/users/user_appearance.rs new file mode 100644 index 0000000..a2246a9 --- /dev/null +++ b/models/users/user_appearance.rs @@ -0,0 +1,18 @@ +use crate::models::common::{ColorScheme, Density, FontSize, Theme}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserAppearance { + pub user_id: Uuid, + pub theme: Theme, + pub color_scheme: ColorScheme, + pub density: Density, + pub font_size: FontSize, + pub editor_theme: Option, + pub markdown_preview: bool, + pub reduced_motion: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/users/user_block.rs b/models/users/user_block.rs new file mode 100644 index 0000000..5bf5b36 --- /dev/null +++ b/models/users/user_block.rs @@ -0,0 +1,11 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserBlock { + pub blocker_id: Uuid, + pub blocked_id: Uuid, + pub reason: Option, + pub created_at: DateTime, +} diff --git a/models/users/user_device.rs b/models/users/user_device.rs new file mode 100644 index 0000000..ad9c33f --- /dev/null +++ b/models/users/user_device.rs @@ -0,0 +1,19 @@ +use crate::models::common::DeviceType; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserDevice { + pub id: Uuid, + pub user_id: Uuid, + pub device_name: String, + pub device_type: DeviceType, + pub fingerprint: Option, + pub ip_address: Option, + pub user_agent: Option, + pub trusted: bool, + pub last_seen_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/users/user_follow.rs b/models/users/user_follow.rs new file mode 100644 index 0000000..07e7ac4 --- /dev/null +++ b/models/users/user_follow.rs @@ -0,0 +1,10 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserFollow { + pub follower_id: Uuid, + pub following_id: Uuid, + pub created_at: DateTime, +} diff --git a/models/users/user_gpg_key.rs b/models/users/user_gpg_key.rs new file mode 100644 index 0000000..178a4f6 --- /dev/null +++ b/models/users/user_gpg_key.rs @@ -0,0 +1,18 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserGpgKey { + pub id: Uuid, + pub user_id: Uuid, + pub key_id: String, + pub public_key: String, + pub fingerprint: String, + pub primary_email: Option, + pub expires_at: Option>, + pub verified_at: Option>, + pub revoked_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/users/user_mail.rs b/models/users/user_mail.rs new file mode 100644 index 0000000..f114b17 --- /dev/null +++ b/models/users/user_mail.rs @@ -0,0 +1,16 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserMail { + pub id: Uuid, + pub user_id: Uuid, + pub email: String, + pub is_primary: bool, + pub is_verified: bool, + pub verification_token_hash: Option, + pub verified_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/users/user_notify_setting.rs b/models/users/user_notify_setting.rs new file mode 100644 index 0000000..9b8c040 --- /dev/null +++ b/models/users/user_notify_setting.rs @@ -0,0 +1,18 @@ +use crate::models::common::DigestFrequency; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserNotifySetting { + pub user_id: Uuid, + pub email_notifications: bool, + pub web_notifications: bool, + pub mention_notifications: bool, + pub review_notifications: bool, + pub security_notifications: bool, + pub marketing_emails: bool, + pub digest_frequency: DigestFrequency, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/users/user_oauth.rs b/models/users/user_oauth.rs new file mode 100644 index 0000000..31cdb46 --- /dev/null +++ b/models/users/user_oauth.rs @@ -0,0 +1,19 @@ +use crate::models::common::Provider; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserOAuth { + pub id: Uuid, + pub user_id: Uuid, + pub provider: Provider, + pub provider_user_id: String, + pub provider_username: Option, + pub provider_email: Option, + pub access_token_ciphertext: Option, + pub refresh_token_ciphertext: Option, + pub token_expires_at: Option>, + pub linked_at: DateTime, + pub last_used_at: Option>, +} diff --git a/models/users/user_password.rs b/models/users/user_password.rs new file mode 100644 index 0000000..5fba3d0 --- /dev/null +++ b/models/users/user_password.rs @@ -0,0 +1,16 @@ +use crate::models::common::PasswordAlgorithm; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserPassword { + pub user_id: Uuid, + pub password_hash: String, + pub password_algo: PasswordAlgorithm, + pub password_salt: Option, + pub must_change_password: bool, + pub password_updated_at: DateTime, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/users/user_password_reset.rs b/models/users/user_password_reset.rs new file mode 100644 index 0000000..2cd4264 --- /dev/null +++ b/models/users/user_password_reset.rs @@ -0,0 +1,15 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserPasswordReset { + pub id: Uuid, + pub user_id: Uuid, + pub token_hash: String, + pub requested_ip: Option, + pub user_agent: Option, + pub expires_at: DateTime, + pub used_at: Option>, + pub created_at: DateTime, +} diff --git a/models/users/user_personal_access_token.rs b/models/users/user_personal_access_token.rs new file mode 100644 index 0000000..8059b24 --- /dev/null +++ b/models/users/user_personal_access_token.rs @@ -0,0 +1,18 @@ +use crate::models::common::Scope; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserPersonalAccessToken { + pub id: Uuid, + pub user_id: Uuid, + pub name: String, + pub token_hash: String, + pub scopes: Vec, + pub last_used_at: Option>, + pub expires_at: Option>, + pub revoked_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/users/user_presence.rs b/models/users/user_presence.rs new file mode 100644 index 0000000..0130ed8 --- /dev/null +++ b/models/users/user_presence.rs @@ -0,0 +1,19 @@ +use crate::models::common::{DeviceType, PresenceStatus}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserPresence { + pub id: Uuid, + pub user_id: Uuid, + pub status: PresenceStatus, + pub custom_status_text: Option, + pub custom_status_emoji: Option, + pub device_type: Option, + pub ip_address: Option, + pub last_active_at: DateTime, + pub last_seen_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/users/user_profile.rs b/models/users/user_profile.rs new file mode 100644 index 0000000..0e7d01e --- /dev/null +++ b/models/users/user_profile.rs @@ -0,0 +1,34 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserProfile { + pub user_id: Uuid, + pub full_name: Option, + pub company: Option, + pub location: Option, + pub website_url: Option, + pub twitter_username: Option, + pub timezone: Option, + pub language: Option, + pub profile_readme: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl UserProfile { + pub async fn find_by_user_id( + pool: &sqlx::PgPool, + user_id: Uuid, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, UserProfile>( + "SELECT user_id, full_name, company, location, website_url, twitter_username, \ + timezone, language, profile_readme, created_at, updated_at \ + FROM user_profile WHERE user_id = $1", + ) + .bind(user_id) + .fetch_optional(pool) + .await + } +} diff --git a/models/users/user_queries.rs b/models/users/user_queries.rs new file mode 100644 index 0000000..41ad221 --- /dev/null +++ b/models/users/user_queries.rs @@ -0,0 +1,99 @@ +//! SQL query methods for the `User` entity. +//! +//! These methods operate on the database directly, returning `sqlx::Error`. +//! The service layer is responsible for converting errors into `AppError`. + +use sqlx::PgPool; +use uuid::Uuid; + +use super::user::User; + +impl User { + /// Find an active, non-deleted user by primary key. + pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>( + r#"SELECT id, username, display_name, avatar_url, bio, status, role, visibility, + is_active, is_bot, last_login_at, created_at, updated_at, deleted_at + FROM "user" + WHERE id = $1 AND is_active = true AND status = 'active' AND deleted_at IS NULL"#, + ) + .bind(id) + .fetch_optional(pool) + .await + } + + /// Find an active user by username (case-insensitive). + pub async fn find_by_username( + pool: &PgPool, + username: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>( + r#"SELECT id, username, display_name, avatar_url, bio, status, role, visibility, + is_active, is_bot, last_login_at, created_at, updated_at, deleted_at + FROM "user" + WHERE lower(username) = lower($1) + AND is_active = true AND status = 'active' AND deleted_at IS NULL"#, + ) + .bind(username) + .fetch_optional(pool) + .await + } + + /// Find an active user by verified email (case-insensitive). + pub async fn find_by_email(pool: &PgPool, email: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>( + r#"SELECT u.id, u.username, u.display_name, u.avatar_url, u.bio, u.status, u.role, + u.visibility, u.is_active, u.is_bot, u.last_login_at, + u.created_at, u.updated_at, u.deleted_at + FROM "user" u + INNER JOIN user_mail e ON e.user_id = u.id + WHERE lower(e.email) = lower($1) + AND e.is_verified = true + AND u.is_active = true AND u.status = 'active' AND u.deleted_at IS NULL"#, + ) + .bind(email) + .fetch_optional(pool) + .await + } + + /// Check if a username already exists (excluding a given user id). + pub async fn username_exists( + pool: &PgPool, + username: &str, + exclude_id: Option, + ) -> Result { + let exists = if let Some(id) = exclude_id { + sqlx::query_scalar::<_, bool>( + r#"SELECT EXISTS( + SELECT 1 FROM "user" + WHERE lower(username) = lower($1) AND id <> $2 AND deleted_at IS NULL + )"#, + ) + .bind(username) + .bind(id) + .fetch_one(pool) + .await? + } else { + sqlx::query_scalar::<_, bool>( + r#"SELECT EXISTS( + SELECT 1 FROM "user" + WHERE lower(username) = lower($1) AND deleted_at IS NULL + )"#, + ) + .bind(username) + .fetch_one(pool) + .await? + }; + Ok(exists) + } + + /// Update the last_login_at timestamp. + pub async fn touch_login(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query(r#"UPDATE "user" SET last_login_at = $1, updated_at = $1 WHERE id = $2"#) + .bind(chrono::Utc::now()) + .bind(id) + .execute(pool) + .await?; + Ok(()) + } +} diff --git a/models/users/user_security_log.rs b/models/users/user_security_log.rs new file mode 100644 index 0000000..afd5b31 --- /dev/null +++ b/models/users/user_security_log.rs @@ -0,0 +1,16 @@ +use crate::models::common::{EventType, JsonValue}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserSecurityLog { + pub id: Uuid, + pub user_id: Uuid, + pub event_type: EventType, + pub description: Option, + pub ip_address: Option, + pub user_agent: Option, + pub metadata: Option, + pub created_at: DateTime, +} diff --git a/models/users/user_session.rs b/models/users/user_session.rs new file mode 100644 index 0000000..d097ab6 --- /dev/null +++ b/models/users/user_session.rs @@ -0,0 +1,17 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserSession { + pub id: Uuid, + pub user_id: Uuid, + pub session_token_hash: String, + pub refresh_token_hash: Option, + pub ip_address: Option, + pub user_agent: Option, + pub last_active_at: DateTime, + pub expires_at: DateTime, + pub revoked_at: Option>, + pub created_at: DateTime, +} diff --git a/models/users/user_ssh_key.rs b/models/users/user_ssh_key.rs new file mode 100644 index 0000000..98d888c --- /dev/null +++ b/models/users/user_ssh_key.rs @@ -0,0 +1,19 @@ +use crate::models::common::KeyType; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserSshKey { + pub id: Uuid, + pub user_id: Uuid, + pub title: String, + pub public_key: String, + pub fingerprint_sha256: String, + pub key_type: KeyType, + pub last_used_at: Option>, + pub expires_at: Option>, + pub revoked_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/wiki/mod.rs b/models/wiki/mod.rs new file mode 100644 index 0000000..fa6cef8 --- /dev/null +++ b/models/wiki/mod.rs @@ -0,0 +1,5 @@ +pub mod wiki_page; +pub mod wiki_page_revision; + +pub use wiki_page::WikiPage; +pub use wiki_page_revision::WikiPageRevision; diff --git a/models/wiki/wiki_page.rs b/models/wiki/wiki_page.rs new file mode 100644 index 0000000..e457100 --- /dev/null +++ b/models/wiki/wiki_page.rs @@ -0,0 +1,18 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)] +pub struct WikiPage { + pub id: Uuid, + pub repo_id: Uuid, + pub slug: String, + pub title: String, + pub content: String, + pub author_id: Uuid, + pub last_editor_id: Option, + pub version: i32, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/models/wiki/wiki_page_revision.rs b/models/wiki/wiki_page_revision.rs new file mode 100644 index 0000000..2b71fbf --- /dev/null +++ b/models/wiki/wiki_page_revision.rs @@ -0,0 +1,15 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)] +pub struct WikiPageRevision { + pub id: Uuid, + pub page_id: Uuid, + pub version: i32, + pub title: String, + pub content: String, + pub editor_id: Uuid, + pub commit_message: Option, + pub created_at: DateTime, +} diff --git a/models/workspaces/mod.rs b/models/workspaces/mod.rs new file mode 100644 index 0000000..c07a519 --- /dev/null +++ b/models/workspaces/mod.rs @@ -0,0 +1,26 @@ +pub mod workspace; +pub mod workspace_audit_logs; +pub mod workspace_billing; +pub mod workspace_custom_branding; +pub mod workspace_domains; +pub mod workspace_integrations; +pub mod workspace_invitations; +pub mod workspace_members; +pub mod workspace_pending_approvals; +pub mod workspace_queries; +pub mod workspace_settings; +pub mod workspace_stats; +pub mod workspace_webhooks; + +pub use workspace::Workspace; +pub use workspace_audit_logs::WorkspaceAuditLog; +pub use workspace_billing::WorkspaceBilling; +pub use workspace_custom_branding::WorkspaceCustomBranding; +pub use workspace_domains::WorkspaceDomain; +pub use workspace_integrations::WorkspaceIntegration; +pub use workspace_invitations::WorkspaceInvitation; +pub use workspace_members::WorkspaceMember; +pub use workspace_pending_approvals::WorkspacePendingApproval; +pub use workspace_settings::WorkspaceSettings; +pub use workspace_stats::WorkspaceStats; +pub use workspace_webhooks::WorkspaceWebhook; diff --git a/models/workspaces/workspace.rs b/models/workspaces/workspace.rs new file mode 100644 index 0000000..ba19967 --- /dev/null +++ b/models/workspaces/workspace.rs @@ -0,0 +1,22 @@ +use crate::models::common::{Status, Visibility}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct Workspace { + pub id: Uuid, + pub owner_id: Uuid, + pub name: String, + pub description: Option, + pub avatar_url: Option, + pub visibility: Visibility, + pub plan: String, + pub status: Status, + pub default_role: String, + pub is_personal: bool, + pub archived_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/models/workspaces/workspace_audit_logs.rs b/models/workspaces/workspace_audit_logs.rs new file mode 100644 index 0000000..64c79a8 --- /dev/null +++ b/models/workspaces/workspace_audit_logs.rs @@ -0,0 +1,18 @@ +use crate::models::common::{JsonValue, TargetType}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct WorkspaceAuditLog { + pub id: Uuid, + pub workspace_id: Uuid, + pub actor_id: Option, + pub action: String, + pub target_type: Option, + pub target_id: Option, + pub ip_address: Option, + pub user_agent: Option, + pub metadata: Option, + pub created_at: DateTime, +} diff --git a/models/workspaces/workspace_billing.rs b/models/workspaces/workspace_billing.rs new file mode 100644 index 0000000..0bff471 --- /dev/null +++ b/models/workspaces/workspace_billing.rs @@ -0,0 +1,21 @@ +use crate::models::common::Status; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct WorkspaceBilling { + pub workspace_id: Uuid, + pub customer_id: Option, + pub subscription_id: Option, + pub plan: String, + pub billing_email: Option, + pub status: Status, + pub seats: i32, + pub trial_ends_at: Option>, + pub current_period_start: Option>, + pub current_period_end: Option>, + pub canceled_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/workspaces/workspace_custom_branding.rs b/models/workspaces/workspace_custom_branding.rs new file mode 100644 index 0000000..50e31e3 --- /dev/null +++ b/models/workspaces/workspace_custom_branding.rs @@ -0,0 +1,17 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct WorkspaceCustomBranding { + pub workspace_id: Uuid, + pub logo_url: Option, + pub favicon_url: Option, + pub primary_color: Option, + pub accent_color: Option, + pub custom_css: Option, + pub support_url: Option, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/workspaces/workspace_domains.rs b/models/workspaces/workspace_domains.rs new file mode 100644 index 0000000..0a68eb8 --- /dev/null +++ b/models/workspaces/workspace_domains.rs @@ -0,0 +1,16 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct WorkspaceDomain { + pub id: Uuid, + pub workspace_id: Uuid, + pub domain: String, + pub verification_token_hash: Option, + pub is_primary: bool, + pub is_verified: bool, + pub verified_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/workspaces/workspace_integrations.rs b/models/workspaces/workspace_integrations.rs new file mode 100644 index 0000000..feea10a --- /dev/null +++ b/models/workspaces/workspace_integrations.rs @@ -0,0 +1,20 @@ +use crate::models::common::Provider; +use crate::models::json_types::{TypedJson, WorkspaceIntegrationConfig}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct WorkspaceIntegration { + pub id: Uuid, + pub workspace_id: Uuid, + pub provider: Provider, + pub name: String, + pub config: Option>, + pub secret_ciphertext: Option, + pub enabled: bool, + pub installed_by: Uuid, + pub last_used_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/workspaces/workspace_invitations.rs b/models/workspaces/workspace_invitations.rs new file mode 100644 index 0000000..74c6bad --- /dev/null +++ b/models/workspaces/workspace_invitations.rs @@ -0,0 +1,19 @@ +use crate::models::common::Role; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)] +pub struct WorkspaceInvitation { + pub id: Uuid, + pub workspace_id: Uuid, + pub email: String, + pub role: Role, + pub token_hash: String, + pub invited_by: Uuid, + pub accepted_by: Option, + pub accepted_at: Option>, + pub revoked_at: Option>, + pub expires_at: DateTime, + pub created_at: DateTime, +} diff --git a/models/workspaces/workspace_members.rs b/models/workspaces/workspace_members.rs new file mode 100644 index 0000000..fbaacae --- /dev/null +++ b/models/workspaces/workspace_members.rs @@ -0,0 +1,18 @@ +use crate::models::common::{Role, Status}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct WorkspaceMember { + pub id: Uuid, + pub workspace_id: Uuid, + pub user_id: Uuid, + pub role: Role, + pub status: Status, + pub invited_by: Option, + pub joined_at: Option>, + pub last_active_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/workspaces/workspace_pending_approvals.rs b/models/workspaces/workspace_pending_approvals.rs new file mode 100644 index 0000000..299b6b3 --- /dev/null +++ b/models/workspaces/workspace_pending_approvals.rs @@ -0,0 +1,19 @@ +use crate::models::common::{RequestType, Status}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct WorkspacePendingApproval { + pub id: Uuid, + pub workspace_id: Uuid, + pub requester_id: Uuid, + pub request_type: RequestType, + pub status: Status, + pub reason: Option, + pub reviewed_by: Option, + pub reviewed_at: Option>, + pub expires_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/workspaces/workspace_queries.rs b/models/workspaces/workspace_queries.rs new file mode 100644 index 0000000..3534dd5 --- /dev/null +++ b/models/workspaces/workspace_queries.rs @@ -0,0 +1,102 @@ +//! SQL query methods for the `Workspace` entity. + +use sqlx::PgPool; +use uuid::Uuid; + +use super::workspace::Workspace; +use crate::models::common::{Role, Visibility}; + +impl Workspace { + /// Find a non-deleted workspace by primary key. + pub async fn find_by_name(pool: &PgPool, name: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Workspace>( + "SELECT id, owner_id, name, description, avatar_url, visibility, plan, status, \ + default_role, is_personal, archived_at, created_at, updated_at, deleted_at \ + FROM workspace WHERE name = $1 AND deleted_at IS NULL", + ) + .bind(name) + .fetch_optional(pool) + .await + } + + pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, Workspace>( + r#"SELECT id, owner_id, name, description, avatar_url, visibility, plan, status, + default_role, is_personal, archived_at, created_at, updated_at, deleted_at + FROM workspace WHERE id = $1 AND deleted_at IS NULL"#, + ) + .bind(id) + .fetch_optional(pool) + .await + } + + /// Count non-deleted workspaces owned by a user. + pub async fn count_owned(pool: &PgPool, owner_id: Uuid) -> Result { + sqlx::query_scalar( + r#"SELECT COUNT(*) FROM workspace WHERE owner_id = $1 AND deleted_at IS NULL"#, + ) + .bind(owner_id) + .fetch_one(pool) + .await + } + + /// Check if a user is an active member of a workspace. + pub async fn is_member( + pool: &PgPool, + workspace_id: Uuid, + user_id: Uuid, + ) -> Result { + sqlx::query_scalar::<_, bool>( + r#"SELECT EXISTS( + SELECT 1 FROM workspace_member + WHERE workspace_id = $1 AND user_id = $2 AND status = 'active' + )"#, + ) + .bind(workspace_id) + .bind(user_id) + .fetch_one(pool) + .await + } + + /// Get the role of a user in a workspace. + /// Returns `Role::Owner` if the user is the workspace owner but has no explicit member row. + pub async fn user_role( + pool: &PgPool, + workspace_id: Uuid, + user_id: Uuid, + owner_id: Uuid, + ) -> Result, sqlx::Error> { + if owner_id == user_id { + return Ok(Some(Role::Owner)); + } + let role_str: Option = sqlx::query_scalar( + r#"SELECT role FROM workspace_member + WHERE workspace_id = $1 AND user_id = $2 AND status = 'active'"#, + ) + .bind(workspace_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + Ok(role_str.and_then(|r| r.parse::().ok())) + } + + /// Check if the user can read the workspace. + /// Readable if: owner, active member, or workspace is public/internal. + pub async fn is_readable( + pool: &PgPool, + ws: &Workspace, + user_id: Uuid, + ) -> Result { + if ws.owner_id == user_id { + return Ok(true); + } + if Self::is_member(pool, ws.id, user_id).await? { + return Ok(true); + } + Ok(matches!( + ws.visibility, + Visibility::Public | Visibility::Internal + )) + } +} diff --git a/models/workspaces/workspace_settings.rs b/models/workspaces/workspace_settings.rs new file mode 100644 index 0000000..53d0d7e --- /dev/null +++ b/models/workspaces/workspace_settings.rs @@ -0,0 +1,18 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct WorkspaceSettings { + pub workspace_id: Uuid, + pub allow_public_repos: bool, + pub allow_member_invites: bool, + pub require_two_factor: bool, + pub default_repo_visibility: String, + pub default_branch_name: String, + pub issue_tracking_enabled: bool, + pub pull_requests_enabled: bool, + pub wiki_enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/models/workspaces/workspace_stats.rs b/models/workspaces/workspace_stats.rs new file mode 100644 index 0000000..8571061 --- /dev/null +++ b/models/workspaces/workspace_stats.rs @@ -0,0 +1,17 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct WorkspaceStats { + pub workspace_id: Uuid, + pub members_count: i64, + pub repos_count: i64, + pub issues_count: i64, + pub pull_requests_count: i64, + pub storage_bytes: i64, + pub bandwidth_bytes: i64, + pub build_minutes_used: i64, + pub last_activity_at: Option>, + pub updated_at: DateTime, +} diff --git a/models/workspaces/workspace_webhooks.rs b/models/workspaces/workspace_webhooks.rs new file mode 100644 index 0000000..30b04d2 --- /dev/null +++ b/models/workspaces/workspace_webhooks.rs @@ -0,0 +1,19 @@ +use crate::models::common::EventType; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +pub struct WorkspaceWebhook { + pub id: Uuid, + pub workspace_id: Uuid, + pub url: String, + pub secret_ciphertext: Option, + pub events: Vec, + pub active: bool, + pub last_delivery_status: Option, + pub last_delivery_at: Option>, + pub created_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/pb/email.rs b/pb/email.rs new file mode 100644 index 0000000..ac02733 --- /dev/null +++ b/pb/email.rs @@ -0,0 +1,4 @@ +// Generated from proto/email/email.proto (package email.v1) +// Compiled via tonic-build in build.rs using OUT_DIR + include! + +include!(concat!(env!("OUT_DIR"), "/email.v1.rs")); diff --git a/pb/mod.rs b/pb/mod.rs new file mode 100644 index 0000000..0b946cf --- /dev/null +++ b/pb/mod.rs @@ -0,0 +1,84 @@ +pub mod email; +pub mod repo; + +use tonic::transport::{Channel, Endpoint}; + +#[derive(Clone)] +pub struct RepoClient { + pub repository: repo::repository_service_client::RepositoryServiceClient, + pub commit: repo::commit_service_client::CommitServiceClient, + pub branch: repo::branch_service_client::BranchServiceClient, + pub tag: repo::tag_service_client::TagServiceClient, + pub tree: repo::tree_service_client::TreeServiceClient, + pub diff: repo::diff_service_client::DiffServiceClient, + pub merge: repo::merge_service_client::MergeServiceClient, + pub blame: repo::blame_service_client::BlameServiceClient, + pub archive: repo::archive_service_client::ArchiveServiceClient, + pub pack: repo::pack_service_client::PackServiceClient, +} + +impl RepoClient { + pub async fn connect(addr: impl Into) -> Result> { + let channel = Endpoint::from_shared(addr.into())?.connect().await?; + Ok(Self::new(channel)) + } + + pub fn lazy_connect(addr: impl Into) -> Result> { + let channel = Endpoint::from_shared(addr.into())?.connect_lazy(); + Ok(Self::new(channel)) + } + + pub fn new(channel: Channel) -> Self { + Self { + repository: repo::repository_service_client::RepositoryServiceClient::new( + channel.clone(), + ), + commit: repo::commit_service_client::CommitServiceClient::new(channel.clone()), + branch: repo::branch_service_client::BranchServiceClient::new(channel.clone()), + tag: repo::tag_service_client::TagServiceClient::new(channel.clone()), + tree: repo::tree_service_client::TreeServiceClient::new(channel.clone()), + diff: repo::diff_service_client::DiffServiceClient::new(channel.clone()), + merge: repo::merge_service_client::MergeServiceClient::new(channel.clone()), + blame: repo::blame_service_client::BlameServiceClient::new(channel.clone()), + archive: repo::archive_service_client::ArchiveServiceClient::new(channel.clone()), + pack: repo::pack_service_client::PackServiceClient::new(channel), + } + } +} + +#[derive(Clone)] +pub struct EmailClient { + inner: email::email_service_client::EmailServiceClient, +} + +impl EmailClient { + pub async fn connect(addr: impl Into) -> Result> { + let channel = Endpoint::from_shared(addr.into())?.connect().await?; + Ok(Self::new(channel)) + } + + pub fn lazy_connect(addr: impl Into) -> Result> { + let channel = Endpoint::from_shared(addr.into())?.connect_lazy(); + Ok(Self::new(channel)) + } + + pub fn new(channel: Channel) -> Self { + Self { + inner: email::email_service_client::EmailServiceClient::new(channel), + } + } +} + +impl std::ops::Deref for EmailClient { + type Target = email::email_service_client::EmailServiceClient; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl std::ops::DerefMut for EmailClient { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} diff --git a/pb/repo.rs b/pb/repo.rs new file mode 100644 index 0000000..51cecc1 --- /dev/null +++ b/pb/repo.rs @@ -0,0 +1,4 @@ +// Generated from proto/git/*.proto (package gitks) +// Compiled via tonic-build in build.rs using OUT_DIR + include! + +include!(concat!(env!("OUT_DIR"), "/gitks.rs")); diff --git a/proto/email/email.proto b/proto/email/email.proto new file mode 100644 index 0000000..1ea884e --- /dev/null +++ b/proto/email/email.proto @@ -0,0 +1,87 @@ +syntax = "proto3"; +package email.v1; + +import "google/protobuf/timestamp.proto"; + + + +enum EmailPriority { + EMAIL_PRIORITY_UNSPECIFIED = 0; + EMAIL_PRIORITY_LOW = 1; + EMAIL_PRIORITY_NORMAL = 2; + EMAIL_PRIORITY_HIGH = 3; +} + +enum SendStatus { + SEND_STATUS_UNSPECIFIED = 0; + SEND_STATUS_QUEUED = 1; + SEND_STATUS_SENT = 2; + SEND_STATUS_FAILED = 3; +} + + + +message EmailAddress { + string email = 1; + string name = 2; +} + +message Attachment { + string filename = 1; + string content_type = 2; + bytes data = 3; + string url = 4; +} + + +message SendEmailRequest { + EmailAddress from = 1; + repeated EmailAddress to = 2; + repeated EmailAddress cc = 3; + repeated EmailAddress bcc = 4; + string subject = 5; + string text_body = 6; + string html_body = 7; + repeated Attachment attachments = 8; + EmailPriority priority = 9; + map headers = 10; + string reply_to = 11; +} + +message SendEmailResponse { + string message_id = 1; + SendStatus status = 2; + string provider = 3; + google.protobuf.Timestamp sent_at = 4; +} + +message BatchSendEmailRequest { + repeated SendEmailRequest emails = 1; + bool fail_fast = 2; +} + +message BatchSendEmailResponse { + repeated SendEmailResponse results = 1; + int32 success_count = 2; + int32 failure_count = 3; +} + +message GetEmailStatusRequest { + string message_id = 1; +} + +message GetEmailStatusResponse { + string message_id = 1; + SendStatus status = 2; + string error_detail = 3; + google.protobuf.Timestamp updated_at = 4; +} + + +service EmailService { + rpc SendEmail(SendEmailRequest) returns (SendEmailResponse); + rpc BatchSendEmail(BatchSendEmailRequest) returns (BatchSendEmailResponse); + rpc GetEmailStatus(GetEmailStatusRequest) returns (GetEmailStatusResponse); + rpc StreamBatchStatus(BatchSendEmailRequest) + returns (stream SendEmailResponse); +} \ No newline at end of file diff --git a/proto/git/archive.proto b/proto/git/archive.proto new file mode 100644 index 0000000..f853037 --- /dev/null +++ b/proto/git/archive.proto @@ -0,0 +1,58 @@ +syntax = "proto3"; + +package gitks; + +import "oid.proto"; +import "repository.proto"; + +message ArchiveOptions { + enum Format { + ARCHIVE_FORMAT_UNSPECIFIED = 0; + ARCHIVE_FORMAT_TAR = 1; + ARCHIVE_FORMAT_TAR_GZ = 2; + ARCHIVE_FORMAT_TAR_BZ2 = 3; + ARCHIVE_FORMAT_TAR_XZ = 4; + ARCHIVE_FORMAT_ZIP = 5; + } + + Format format = 1; + string prefix = 2; + repeated string pathspec = 3; + int32 compression_level = 4; + bool include_global_extended_pax_headers = 5; +} + +message ArchiveRequest { + RepositoryHeader repository = 1; + ObjectSelector treeish = 2; + ArchiveOptions options = 3; +} + +message ArchiveChunk { + bytes data = 1; +} + +message ArchiveEntry { + string path = 1; + Oid oid = 2; + uint32 mode = 3; + int64 size = 4; + ObjectType type = 5; +} + +message ListArchiveEntriesRequest { + RepositoryHeader repository = 1; + ObjectSelector treeish = 2; + repeated string pathspec = 3; + Pagination pagination = 4; +} + +message ListArchiveEntriesResponse { + repeated ArchiveEntry entries = 1; + PageInfo page_info = 2; +} + +service ArchiveService { + rpc GetArchive(ArchiveRequest) returns (stream ArchiveChunk); + rpc ListArchiveEntries(ListArchiveEntriesRequest) returns (ListArchiveEntriesResponse); +} diff --git a/proto/git/blame.proto b/proto/git/blame.proto new file mode 100644 index 0000000..0336cb2 --- /dev/null +++ b/proto/git/blame.proto @@ -0,0 +1,56 @@ +syntax = "proto3"; + +package gitks; + +import "commit.proto"; +import "oid.proto"; +import "repository.proto"; + +message LineRange { + uint32 start = 1; + uint32 end = 2; +} + +message BlameOptions { + bool detect_move = 1; + bool detect_copy = 2; + uint32 score = 3; + repeated string ignore_revisions = 4; +} + +message BlameRequest { + RepositoryHeader repository = 1; + ObjectSelector revision = 2; + string path = 3; + LineRange range = 4; + BlameOptions options = 5; + Pagination pagination = 6; +} + +message BlameLine { + uint32 final_line = 1; + uint32 original_line = 2; + bytes content = 3; +} + +message BlameHunk { + Commit commit = 1; + string original_path = 2; + string final_path = 3; + uint32 original_start_line = 4; + uint32 final_start_line = 5; + uint32 line_count = 6; + bool boundary = 7; + repeated BlameLine lines = 8; +} + +message BlameResponse { + repeated BlameHunk hunks = 1; + PageInfo page_info = 2; + bool truncated = 3; +} + +service BlameService { + rpc Blame(BlameRequest) returns (BlameResponse); + rpc StreamBlame(BlameRequest) returns (stream BlameHunk); +} diff --git a/proto/git/branch.proto b/proto/git/branch.proto new file mode 100644 index 0000000..3411d19 --- /dev/null +++ b/proto/git/branch.proto @@ -0,0 +1,114 @@ +syntax = "proto3"; + +package gitks; + +import "google/protobuf/empty.proto"; +import "commit.proto"; +import "oid.proto"; +import "repository.proto"; + +message BranchUpstream { + string remote_name = 1; + string remote_url = 2; + string remote_branch_name = 3; + string local_branch_name = 4; +} + +message Branch { + string name = 1; + string full_ref = 2; + Oid target_oid = 3; + Commit commit = 4; + BranchUpstream upstream = 5; + bool is_default = 6; + bool is_head = 7; + bool is_merged = 8; + bool is_detached = 9; +} + +// Backward-compatible payload name used by earlier clients. +message PayloadBranch { + PayloadCommit commit = 1; + string name = 2; + BranchUpstream upstream = 3; + bool is_head = 4; +} + +message RequestBranchInit {} + +message ListBranchesRequest { + RepositoryHeader repository = 1; + string pattern = 2; + bool merged_into_head = 3; + bool not_merged_into_head = 4; + Pagination pagination = 5; + SortDirection sort_direction = 6; +} + +message ListBranchesResponse { + repeated Branch branches = 1; + PageInfo page_info = 2; +} + +message GetBranchRequest { + RepositoryHeader repository = 1; + string name = 2; +} + +message CreateBranchRequest { + RepositoryHeader repository = 1; + string name = 2; + ObjectSelector start_point = 3; + bool force = 4; +} + +message DeleteBranchRequest { + RepositoryHeader repository = 1; + string name = 2; + bool force = 3; +} + +message RenameBranchRequest { + RepositoryHeader repository = 1; + string old_name = 2; + string new_name = 3; +} + +message UpdateBranchTargetRequest { + RepositoryHeader repository = 1; + string name = 2; + Oid expected_old_oid = 3; + Oid new_oid = 4; + bool force = 5; +} + +message SetBranchUpstreamRequest { + RepositoryHeader repository = 1; + string name = 2; + BranchUpstream upstream = 3; +} + +message CompareBranchRequest { + RepositoryHeader repository = 1; + string source_branch = 2; + string target_branch = 3; +} + +message CompareBranchResponse { + bool ahead = 1; + bool behind = 2; + uint32 ahead_by = 3; + uint32 behind_by = 4; + Oid merge_base = 5; +} + +service BranchService { + rpc ListBranches(ListBranchesRequest) returns (ListBranchesResponse); + rpc GetBranch(GetBranchRequest) returns (Branch); + rpc CreateBranch(CreateBranchRequest) returns (Branch); + rpc DeleteBranch(DeleteBranchRequest) returns (google.protobuf.Empty); + rpc RenameBranch(RenameBranchRequest) returns (Branch); + rpc UpdateBranchTarget(UpdateBranchTargetRequest) returns (Branch); + rpc SetBranchUpstream(SetBranchUpstreamRequest) returns (Branch); + rpc CompareBranch(CompareBranchRequest) returns (CompareBranchResponse); +} diff --git a/proto/git/commit.proto b/proto/git/commit.proto new file mode 100644 index 0000000..ec293fe --- /dev/null +++ b/proto/git/commit.proto @@ -0,0 +1,165 @@ +syntax = "proto3"; + +package gitks; + +import "google/protobuf/timestamp.proto"; +import "oid.proto"; +import "repository.proto"; +import "tagger.proto"; + +message PayloadCommit { + PayloadTagger author = 1; + PayloadTagger committer = 2; + Oid oid = 3; + string message = 4; + repeated Oid parents = 5; + Oid tree = 6; + google.protobuf.Timestamp timestamp = 7; +} + +message CommitTrailer { + string key = 1; + string value = 2; + bool separator_present = 3; +} + +message CommitStats { + uint32 additions = 1; + uint32 deletions = 2; + uint32 changed_files = 3; +} + +message Commit { + Oid oid = 1; + string abbreviated_oid = 2; + repeated Oid parent_oids = 3; + Oid tree_oid = 4; + Signature author = 5; + Signature committer = 6; + string subject = 7; + string body = 8; + string message = 9; + repeated CommitTrailer trailers = 10; + VerifiedSignature signature = 11; + CommitStats stats = 12; + google.protobuf.Timestamp authored_at = 13; + google.protobuf.Timestamp committed_at = 14; + bytes raw = 15; +} + +message ListCommitsRequest { + RepositoryHeader repository = 1; + ObjectSelector revision = 2; + string path = 3; + google.protobuf.Timestamp since = 4; + google.protobuf.Timestamp until = 5; + bool first_parent = 6; + bool all = 7; + bool reverse = 8; + uint32 max_parents = 9; + uint32 min_parents = 10; + Pagination pagination = 11; +} + +message ListCommitsResponse { + repeated Commit commits = 1; + PageInfo page_info = 2; +} + +message GetCommitRequest { + RepositoryHeader repository = 1; + ObjectSelector revision = 2; + bool include_stats = 3; + bool include_raw = 4; +} + +message GetCommitAncestorsRequest { + RepositoryHeader repository = 1; + ObjectSelector revision = 2; + bool first_parent = 3; + Pagination pagination = 4; +} + +message GetCommitAncestorsResponse { + repeated Commit commits = 1; + PageInfo page_info = 2; +} + +message CreateCommitAction { + enum Action { + CREATE_COMMIT_ACTION_UNSPECIFIED = 0; + CREATE_COMMIT_ACTION_CREATE = 1; + CREATE_COMMIT_ACTION_UPDATE = 2; + CREATE_COMMIT_ACTION_DELETE = 3; + CREATE_COMMIT_ACTION_MOVE = 4; + CREATE_COMMIT_ACTION_CHMOD = 5; + } + + Action action = 1; + string file_path = 2; + string previous_path = 3; + bytes content = 4; + string encoding = 5; + bool executable = 6; + Oid last_commit_oid = 7; +} + +message CreateCommitRequest { + RepositoryHeader repository = 1; + string branch = 2; + string message = 3; + Signature author = 4; + Signature committer = 5; + repeated CreateCommitAction actions = 6; + ObjectSelector start_revision = 7; + bool force = 8; + repeated CommitTrailer trailers = 9; +} + +message CreateCommitResponse { + Commit commit = 1; + string branch = 2; +} + +message RevertCommitRequest { + RepositoryHeader repository = 1; + ObjectSelector commit = 2; + string branch = 3; + Signature committer = 4; + string message = 5; +} + +message CherryPickCommitRequest { + RepositoryHeader repository = 1; + ObjectSelector commit = 2; + string branch = 3; + Signature committer = 4; + string message = 5; + uint32 mainline = 6; +} + +message CompareCommitsRequest { + RepositoryHeader repository = 1; + ObjectSelector base = 2; + ObjectSelector head = 3; + bool straight = 4; + bool first_parent = 5; + Pagination pagination = 6; +} + +message CompareCommitsResponse { + repeated Commit commits = 1; + CommitStats stats = 2; + PageInfo page_info = 3; + Oid merge_base = 4; +} + +service CommitService { + rpc ListCommits(ListCommitsRequest) returns (ListCommitsResponse); + rpc GetCommit(GetCommitRequest) returns (Commit); + rpc GetCommitAncestors(GetCommitAncestorsRequest) returns (GetCommitAncestorsResponse); + rpc CreateCommit(CreateCommitRequest) returns (CreateCommitResponse); + rpc RevertCommit(RevertCommitRequest) returns (CreateCommitResponse); + rpc CherryPickCommit(CherryPickCommitRequest) returns (CreateCommitResponse); + rpc CompareCommits(CompareCommitsRequest) returns (CompareCommitsResponse); +} diff --git a/proto/git/diff.proto b/proto/git/diff.proto new file mode 100644 index 0000000..8d6b346 --- /dev/null +++ b/proto/git/diff.proto @@ -0,0 +1,140 @@ +syntax = "proto3"; + +package gitks; + +import "oid.proto"; +import "repository.proto"; + +message DiffOptions { + enum WhitespaceMode { + DIFF_WHITESPACE_MODE_UNSPECIFIED = 0; + DIFF_WHITESPACE_MODE_DEFAULT = 1; + DIFF_WHITESPACE_MODE_IGNORE_ALL = 2; + DIFF_WHITESPACE_MODE_IGNORE_CHANGE = 3; + DIFF_WHITESPACE_MODE_IGNORE_EOL = 4; + } + + bool recursive = 1; + bool include_binary = 2; + bool include_patch = 3; + bool rename_detection = 4; + bool copy_detection = 5; + uint32 context_lines = 6; + repeated string pathspec = 7; + WhitespaceMode whitespace_mode = 8; + uint64 max_files = 9; + uint64 max_bytes = 10; +} + +message DiffLine { + enum LineType { + DIFF_LINE_TYPE_UNSPECIFIED = 0; + DIFF_LINE_TYPE_CONTEXT = 1; + DIFF_LINE_TYPE_ADDED = 2; + DIFF_LINE_TYPE_DELETED = 3; + DIFF_LINE_TYPE_HUNK_HEADER = 4; + DIFF_LINE_TYPE_NO_NEWLINE = 5; + } + + LineType type = 1; + int32 old_line = 2; + int32 new_line = 3; + bytes content = 4; + bool truncated = 5; +} + +message DiffHunk { + string header = 1; + uint32 old_start = 2; + uint32 old_lines = 3; + uint32 new_start = 4; + uint32 new_lines = 5; + repeated DiffLine lines = 6; +} + +message DiffFile { + enum ChangeType { + DIFF_FILE_CHANGE_TYPE_UNSPECIFIED = 0; + DIFF_FILE_CHANGE_TYPE_ADDED = 1; + DIFF_FILE_CHANGE_TYPE_MODIFIED = 2; + DIFF_FILE_CHANGE_TYPE_DELETED = 3; + DIFF_FILE_CHANGE_TYPE_RENAMED = 4; + DIFF_FILE_CHANGE_TYPE_COPIED = 5; + DIFF_FILE_CHANGE_TYPE_TYPE_CHANGED = 6; + DIFF_FILE_CHANGE_TYPE_UNMERGED = 7; + } + + string old_path = 1; + string new_path = 2; + Oid old_oid = 3; + Oid new_oid = 4; + uint32 old_mode = 5; + uint32 new_mode = 6; + ChangeType change_type = 7; + bool binary = 8; + bool too_large = 9; + uint32 additions = 10; + uint32 deletions = 11; + repeated DiffHunk hunks = 12; + bytes patch = 13; + double similarity = 14; +} + +message DiffStats { + uint32 additions = 1; + uint32 deletions = 2; + uint32 changed_files = 3; +} + +message Diff { + repeated DiffFile files = 1; + DiffStats stats = 2; + bool overflow = 3; +} + +message GetDiffRequest { + RepositoryHeader repository = 1; + ObjectSelector base = 2; + ObjectSelector head = 3; + DiffOptions options = 4; + Pagination pagination = 5; +} + +message GetDiffResponse { + repeated DiffFile files = 1; + DiffStats stats = 2; + PageInfo page_info = 3; + bool overflow = 4; +} + +message GetCommitDiffRequest { + RepositoryHeader repository = 1; + ObjectSelector commit = 2; + DiffOptions options = 3; + Pagination pagination = 4; +} + +message GetPatchRequest { + RepositoryHeader repository = 1; + ObjectSelector base = 2; + ObjectSelector head = 3; + DiffOptions options = 4; +} + +message GetPatchResponse { + bytes data = 1; +} + +message GetDiffStatsRequest { + RepositoryHeader repository = 1; + ObjectSelector base = 2; + ObjectSelector head = 3; + DiffOptions options = 4; +} + +service DiffService { + rpc GetDiff(GetDiffRequest) returns (GetDiffResponse); + rpc GetCommitDiff(GetCommitDiffRequest) returns (GetDiffResponse); + rpc GetPatch(GetPatchRequest) returns (stream GetPatchResponse); + rpc GetDiffStats(GetDiffStatsRequest) returns (DiffStats); +} diff --git a/proto/git/merge.proto b/proto/git/merge.proto new file mode 100644 index 0000000..e0ff940 --- /dev/null +++ b/proto/git/merge.proto @@ -0,0 +1,139 @@ +syntax = "proto3"; + +package gitks; + +import "commit.proto"; +import "diff.proto"; +import "oid.proto"; +import "repository.proto"; +import "tagger.proto"; + +message MergeOptions { + enum Strategy { + MERGE_STRATEGY_UNSPECIFIED = 0; + MERGE_STRATEGY_RECURSIVE = 1; + MERGE_STRATEGY_ORT = 2; + MERGE_STRATEGY_RESOLVE = 3; + MERGE_STRATEGY_OCTOPUS = 4; + MERGE_STRATEGY_OURS = 5; + MERGE_STRATEGY_SUBTREE = 6; + } + + enum FastForwardMode { + MERGE_FAST_FORWARD_MODE_UNSPECIFIED = 0; + MERGE_FAST_FORWARD_MODE_ALLOWED = 1; + MERGE_FAST_FORWARD_MODE_ONLY = 2; + MERGE_FAST_FORWARD_MODE_NO_FF = 3; + } + + Strategy strategy = 1; + FastForwardMode fast_forward = 2; + bool squash = 3; + bool no_commit = 4; + bool allow_unrelated_histories = 5; + repeated string strategy_options = 6; +} + +message MergeConflictSection { + string label = 1; + bytes content = 2; +} + +message MergeConflict { + string path = 1; + uint32 mode = 2; + Oid base_oid = 3; + Oid ours_oid = 4; + Oid theirs_oid = 5; + repeated MergeConflictSection sections = 6; + bool binary = 7; +} + +message MergeResult { + enum Status { + MERGE_RESULT_STATUS_UNSPECIFIED = 0; + MERGE_RESULT_STATUS_MERGED = 1; + MERGE_RESULT_STATUS_FAST_FORWARD = 2; + MERGE_RESULT_STATUS_ALREADY_UP_TO_DATE = 3; + MERGE_RESULT_STATUS_CONFLICTS = 4; + MERGE_RESULT_STATUS_ABORTED = 5; + } + + Status status = 1; + Commit commit = 2; + Oid merge_base = 3; + repeated MergeConflict conflicts = 4; + DiffStats stats = 5; + string message = 6; +} + +message MergeRequest { + RepositoryHeader repository = 1; + string target_branch = 2; + ObjectSelector source = 3; + Signature committer = 4; + string message = 5; + MergeOptions options = 6; +} + +message CheckMergeRequest { + RepositoryHeader repository = 1; + ObjectSelector target = 2; + ObjectSelector source = 3; + MergeOptions options = 4; +} + +message ListMergeConflictsRequest { + RepositoryHeader repository = 1; + ObjectSelector target = 2; + ObjectSelector source = 3; + Pagination pagination = 4; +} + +message ListMergeConflictsResponse { + repeated MergeConflict conflicts = 1; + PageInfo page_info = 2; +} + +message ResolveMergeConflict { + string path = 1; + bytes content = 2; +} + +message ResolveMergeConflictsRequest { + RepositoryHeader repository = 1; + string target_branch = 2; + ObjectSelector source = 3; + repeated ResolveMergeConflict resolutions = 4; + Signature committer = 5; + string message = 6; +} + +message RebaseRequest { + RepositoryHeader repository = 1; + string branch = 2; + ObjectSelector upstream = 3; + Signature committer = 4; +} + +message RebaseResult { + enum Status { + REBASE_RESULT_STATUS_UNSPECIFIED = 0; + REBASE_RESULT_STATUS_REBASED = 1; + REBASE_RESULT_STATUS_ALREADY_UP_TO_DATE = 2; + REBASE_RESULT_STATUS_CONFLICTS = 3; + REBASE_RESULT_STATUS_ABORTED = 4; + } + + Status status = 1; + Commit head = 2; + repeated MergeConflict conflicts = 3; +} + +service MergeService { + rpc CheckMerge(CheckMergeRequest) returns (MergeResult); + rpc Merge(MergeRequest) returns (MergeResult); + rpc ListMergeConflicts(ListMergeConflictsRequest) returns (ListMergeConflictsResponse); + rpc ResolveMergeConflicts(ResolveMergeConflictsRequest) returns (MergeResult); + rpc Rebase(RebaseRequest) returns (RebaseResult); +} diff --git a/proto/git/oid.proto b/proto/git/oid.proto new file mode 100644 index 0000000..f5af413 --- /dev/null +++ b/proto/git/oid.proto @@ -0,0 +1,64 @@ +syntax = "proto3"; + +package gitks; + +// Git object hash algorithm. GitHub and Gitaly both need to support SHA-1 today +// and SHA-256 repositories as they become more common. +enum ObjectFormat { + OBJECT_FORMAT_UNSPECIFIED = 0; + OBJECT_FORMAT_SHA1 = 1; + OBJECT_FORMAT_SHA256 = 2; +} + +// Git object kind. +enum ObjectType { + OBJECT_TYPE_UNSPECIFIED = 0; + OBJECT_TYPE_COMMIT = 1; + OBJECT_TYPE_TREE = 2; + OBJECT_TYPE_BLOB = 3; + OBJECT_TYPE_TAG = 4; +} + +// Canonical object id. `value` preserves the original binary representation used +// by the existing API; `hex` is the normalized lowercase hex form for clients. +message Oid { + bytes value = 1; + string hex = 2; + ObjectFormat format = 3; +} + +message ObjectName { + // Revision expression, refname, oid hex, or pseudo-ref such as HEAD. + string revision = 1; +} + +message ObjectSelector { + oneof selector { + Oid oid = 1; + ObjectName revision = 2; + } +} + +message ObjectIdentity { + Oid oid = 1; + ObjectType type = 2; + int64 size = 3; + string abbreviated_oid = 4; +} + +message Pagination { + uint32 page_size = 1; + string page_token = 2; +} + +message PageInfo { + string next_page_token = 1; + bool has_next_page = 2; + uint64 total_count = 3; +} + +enum SortDirection { + SORT_DIRECTION_UNSPECIFIED = 0; + SORT_DIRECTION_ASC = 1; + SORT_DIRECTION_DESC = 2; +} diff --git a/proto/git/pack.proto b/proto/git/pack.proto new file mode 100644 index 0000000..b3ee87b --- /dev/null +++ b/proto/git/pack.proto @@ -0,0 +1,134 @@ +syntax = "proto3"; + +package gitks; + +import "oid.proto"; +import "repository.proto"; + +message GitProtocolFeatures { + uint32 version = 1; + repeated string capabilities = 2; + repeated string server_options = 3; + repeated string agent = 4; +} + +message ReferenceAdvertisement { + string name = 1; + Oid target_oid = 2; + Oid peeled_oid = 3; + bool symbolic = 4; + string symbolic_target = 5; +} + +message AdvertiseRefsRequest { + RepositoryHeader repository = 1; + GitProtocolFeatures protocol = 2; + string service = 3; +} + +message AdvertiseRefsResponse { + repeated ReferenceAdvertisement references = 1; + repeated string capabilities = 2; +} + +message UploadPackRequest { + RepositoryHeader repository = 1; + GitProtocolFeatures protocol = 2; + bytes packet = 3; + bool done = 4; +} + +message UploadPackResponse { + bytes packet = 1; + string stderr = 2; +} + +message ReceivePackRequest { + RepositoryHeader repository = 1; + GitProtocolFeatures protocol = 2; + bytes packet = 3; + bool done = 4; +} + +message ReceivePackResponse { + bytes packet = 1; + string stderr = 2; +} + +message PackObjectsOptions { + repeated Oid wants = 1; + repeated Oid haves = 2; + repeated string shallow_revisions = 3; + uint32 deepen = 4; + bool thin_pack = 5; + bool include_tag = 6; + bool use_bitmaps = 7; + bool delta_base_offset = 8; + repeated string pathspec = 9; +} + +message PackObjectsRequest { + RepositoryHeader repository = 1; + PackObjectsOptions options = 2; +} + +message PackfileChunk { + bytes data = 1; +} + +message IndexPackRequest { + RepositoryHeader repository = 1; + bytes data = 2; + bool done = 3; + bool keep = 4; + bool strict = 5; +} + +message IndexPackResponse { + Oid pack_hash = 1; + uint64 object_count = 2; + string stderr = 3; +} + +message ListPackfilesRequest { + RepositoryHeader repository = 1; + Pagination pagination = 2; +} + +message PackfileInfo { + string name = 1; + Oid pack_hash = 2; + uint64 size_bytes = 3; + uint64 index_size_bytes = 4; + uint64 object_count = 5; + bool has_bitmap = 6; + bool has_rev_index = 7; + bool kept = 8; +} + +message ListPackfilesResponse { + repeated PackfileInfo packfiles = 1; + PageInfo page_info = 2; +} + +message FsckRequest { + RepositoryHeader repository = 1; + bool strict = 2; + bool connectivity_only = 3; +} + +message FsckResponse { + bool ok = 1; + repeated string errors = 2; + repeated string warnings = 3; +} + +service PackService { + rpc AdvertiseRefs(AdvertiseRefsRequest) returns (AdvertiseRefsResponse); + rpc UploadPack(stream UploadPackRequest) returns (stream UploadPackResponse); + rpc ReceivePack(stream ReceivePackRequest) returns (stream ReceivePackResponse); + rpc PackObjects(PackObjectsRequest) returns (stream PackfileChunk); + rpc IndexPack(stream IndexPackRequest) returns (IndexPackResponse); + rpc ListPackfiles(ListPackfilesRequest) returns (ListPackfilesResponse); + rpc Fsck(FsckRequest) returns (FsckResponse); +} diff --git a/proto/git/repository.proto b/proto/git/repository.proto new file mode 100644 index 0000000..d9b1ebb --- /dev/null +++ b/proto/git/repository.proto @@ -0,0 +1,157 @@ +syntax = "proto3"; + +package gitks; + +import "google/protobuf/empty.proto"; +import "oid.proto"; + +// Repository identity used by storage-facing RPCs. +message RepositoryHeader { + // Logical storage shard or disk name. + string storage_name = 1; + // Path relative to the storage root, usually ending in `.git` for bare repos. + string relative_path = 2; + // Optional absolute path for embedded/local deployments. + string storage_path = 3; +} + +message Repository { + RepositoryHeader header = 1; + bool bare = 2; + bool empty = 3; + ObjectFormat object_format = 4; + string default_branch = 5; + string git_object_directory = 6; + repeated string git_alternate_object_directories = 7; +} + +message RepositoryStatistics { + uint64 size_bytes = 1; + uint64 loose_object_count = 2; + uint64 packed_object_count = 3; + uint64 packfile_count = 4; + uint64 reference_count = 5; + uint64 commit_graph_size_bytes = 6; + uint64 multi_pack_index_size_bytes = 7; +} + +message RepositoryConfigEntry { + string key = 1; + repeated string values = 2; +} + +message RepositoryObjectFormatRequest { + RepositoryHeader repository = 1; +} + +message RepositoryObjectFormatResponse { + ObjectFormat object_format = 1; +} + +message GetRepositoryRequest { + RepositoryHeader repository = 1; +} + +message InitRepositoryRequest { + RepositoryHeader repository = 1; + bool bare = 2; + ObjectFormat object_format = 3; + string initial_branch = 4; +} + +message DeleteRepositoryRequest { + RepositoryHeader repository = 1; +} + +message RepositoryExistsRequest { + RepositoryHeader repository = 1; +} + +message RepositoryExistsResponse { + bool exists = 1; +} + +message GetDefaultBranchRequest { + RepositoryHeader repository = 1; +} + +message GetDefaultBranchResponse { + string name = 1; +} + +message SetDefaultBranchRequest { + RepositoryHeader repository = 1; + string name = 2; +} + +message GetRepositoryConfigRequest { + RepositoryHeader repository = 1; + repeated string keys = 2; +} + +message GetRepositoryConfigResponse { + repeated RepositoryConfigEntry entries = 1; +} + +message SetRepositoryConfigRequest { + RepositoryHeader repository = 1; + repeated RepositoryConfigEntry entries = 2; +} + +message RepositoryStatisticsRequest { + RepositoryHeader repository = 1; +} + +message RepositoryHealthRequest { + RepositoryHeader repository = 1; + bool connectivity_only = 2; +} + +message RepositoryHealthResponse { + bool ok = 1; + repeated string warnings = 2; + repeated string errors = 3; + RepositoryStatistics statistics = 4; +} + +message GarbageCollectRequest { + RepositoryHeader repository = 1; + bool prune = 2; + bool aggressive = 3; +} + +message RepackRequest { + RepositoryHeader repository = 1; + bool full = 2; + bool write_bitmaps = 3; + bool write_multi_pack_index = 4; +} + +message WriteCommitGraphRequest { + RepositoryHeader repository = 1; + bool replace = 2; + bool split = 3; +} + +message RepositoryMaintenanceResponse { + bool ok = 1; + string stdout = 2; + string stderr = 3; +} + +service RepositoryService { + rpc GetRepository(GetRepositoryRequest) returns (Repository); + rpc InitRepository(InitRepositoryRequest) returns (Repository); + rpc DeleteRepository(DeleteRepositoryRequest) returns (google.protobuf.Empty); + rpc RepositoryExists(RepositoryExistsRequest) returns (RepositoryExistsResponse); + rpc GetObjectFormat(RepositoryObjectFormatRequest) returns (RepositoryObjectFormatResponse); + rpc GetDefaultBranch(GetDefaultBranchRequest) returns (GetDefaultBranchResponse); + rpc SetDefaultBranch(SetDefaultBranchRequest) returns (google.protobuf.Empty); + rpc GetRepositoryConfig(GetRepositoryConfigRequest) returns (GetRepositoryConfigResponse); + rpc SetRepositoryConfig(SetRepositoryConfigRequest) returns (google.protobuf.Empty); + rpc GetRepositoryStatistics(RepositoryStatisticsRequest) returns (RepositoryStatistics); + rpc CheckRepositoryHealth(RepositoryHealthRequest) returns (RepositoryHealthResponse); + rpc GarbageCollect(GarbageCollectRequest) returns (RepositoryMaintenanceResponse); + rpc Repack(RepackRequest) returns (RepositoryMaintenanceResponse); + rpc WriteCommitGraph(WriteCommitGraphRequest) returns (RepositoryMaintenanceResponse); +} diff --git a/proto/git/tag.proto b/proto/git/tag.proto new file mode 100644 index 0000000..fc02e2e --- /dev/null +++ b/proto/git/tag.proto @@ -0,0 +1,67 @@ +syntax = "proto3"; + +package gitks; + +import "google/protobuf/empty.proto"; +import "oid.proto"; +import "repository.proto"; +import "tagger.proto"; + +message Tag { + string name = 1; + string full_ref = 2; + Oid target_oid = 3; + ObjectType target_type = 4; + Oid tag_oid = 5; + bool annotated = 6; + Signature tagger = 7; + string message = 8; + VerifiedSignature signature = 9; + bytes raw = 10; +} + +message ListTagsRequest { + RepositoryHeader repository = 1; + string pattern = 2; + Pagination pagination = 3; + SortDirection sort_direction = 4; +} + +message ListTagsResponse { + repeated Tag tags = 1; + PageInfo page_info = 2; +} + +message GetTagRequest { + RepositoryHeader repository = 1; + string name = 2; + bool include_raw = 3; +} + +message CreateTagRequest { + RepositoryHeader repository = 1; + string name = 2; + ObjectSelector target = 3; + string message = 4; + Signature tagger = 5; + bool force = 6; + bool annotated = 7; +} + +message DeleteTagRequest { + RepositoryHeader repository = 1; + string name = 2; +} + +message VerifyTagRequest { + RepositoryHeader repository = 1; + string name = 2; +} + +service TagService { + rpc ListTags(ListTagsRequest) returns (ListTagsResponse); + rpc GetTag(GetTagRequest) returns (Tag); + rpc CreateTag(CreateTagRequest) returns (Tag); + rpc DeleteTag(DeleteTagRequest) returns (google.protobuf.Empty); + rpc VerifyTag(VerifyTagRequest) returns (VerifiedSignature); +} diff --git a/proto/git/tagger.proto b/proto/git/tagger.proto new file mode 100644 index 0000000..cdfb408 --- /dev/null +++ b/proto/git/tagger.proto @@ -0,0 +1,51 @@ +syntax = "proto3"; + +package gitks; + +import "google/protobuf/timestamp.proto"; + +// Git identity attached to commits and tags. +message Identity { + string name = 1; + string email = 2; +} + +// Git signature with timestamp and timezone offset. +message Signature { + Identity identity = 1; + google.protobuf.Timestamp when = 2; + // Offset in minutes east of UTC, as stored by git. + int32 timezone_offset = 3; +} + +// Backward-compatible payload name used by earlier Rust structs. +message PayloadTagger { + string email = 1; + string name = 2; +} + +message VerifiedSignature { + enum Reason { + REASON_UNSPECIFIED = 0; + REASON_VALID = 1; + REASON_EXPIRED_KEY = 2; + REASON_NOT_SIGNING_KEY = 3; + REASON_GPGVERIFY_ERROR = 4; + REASON_GPGVERIFY_UNAVAILABLE = 5; + REASON_UNSIGNED = 6; + REASON_UNKNOWN_SIGNATURE_TYPE = 7; + REASON_NO_USER = 8; + REASON_UNVERIFIED_EMAIL = 9; + REASON_BAD_EMAIL = 10; + REASON_UNKNOWN_KEY = 11; + REASON_MALFORMED_SIGNATURE = 12; + REASON_INVALID = 13; + } + + bool verified = 1; + Reason reason = 2; + string signature = 3; + string payload = 4; + string key_fingerprint = 5; + string signer = 6; +} diff --git a/proto/git/tree.proto b/proto/git/tree.proto new file mode 100644 index 0000000..a8db5f5 --- /dev/null +++ b/proto/git/tree.proto @@ -0,0 +1,130 @@ +syntax = "proto3"; + +package gitks; + +import "oid.proto"; +import "repository.proto"; + +message RecentCommit { + Oid oid = 1; + string subject = 2; + int64 committed_timestamp = 3; +} + +message TreeEntry { + enum EntryType { + TREE_ENTRY_TYPE_UNSPECIFIED = 0; + TREE_ENTRY_TYPE_TREE = 1; + TREE_ENTRY_TYPE_BLOB = 2; + TREE_ENTRY_TYPE_COMMIT = 3; + TREE_ENTRY_TYPE_SYMLINK = 4; + TREE_ENTRY_TYPE_EXECUTABLE = 5; + } + + string name = 1; + string path = 2; + Oid oid = 3; + EntryType type = 4; + uint32 mode = 5; + int64 size = 6; + bool is_lfs = 7; + RecentCommit recent_commit = 8; +} + +message Tree { + Oid oid = 1; + string path = 2; + repeated TreeEntry entries = 3; + bool truncated = 4; +} + +message Blob { + Oid oid = 1; + string path = 2; + uint32 mode = 3; + int64 size = 4; + bytes data = 5; + string encoding = 6; + bool binary = 7; + bool truncated = 8; + bool is_lfs = 9; + RecentCommit recent_commit = 10; +} + +message FileMetadata { + string path = 1; + Oid oid = 2; + uint32 mode = 3; + int64 size = 4; + ObjectType type = 5; + bool binary = 6; + bool is_lfs = 7; + RecentCommit recent_commit = 8; +} + +message ListTreeRequest { + RepositoryHeader repository = 1; + ObjectSelector revision = 2; + string path = 3; + bool recursive = 4; + Pagination pagination = 5; +} + +message ListTreeResponse { + repeated TreeEntry entries = 1; + PageInfo page_info = 2; + bool truncated = 3; +} + +message GetTreeRequest { + RepositoryHeader repository = 1; + ObjectSelector revision = 2; + string path = 3; +} + +message GetBlobRequest { + RepositoryHeader repository = 1; + ObjectSelector revision = 2; + string path = 3; + Oid oid = 4; + uint64 max_bytes = 5; +} + +message GetRawBlobRequest { + RepositoryHeader repository = 1; + ObjectSelector revision = 2; + string path = 3; + Oid oid = 4; +} + +message GetRawBlobResponse { + bytes data = 1; +} + +message GetFileMetadataRequest { + RepositoryHeader repository = 1; + ObjectSelector revision = 2; + string path = 3; +} + +message FindFilesRequest { + RepositoryHeader repository = 1; + ObjectSelector revision = 2; + string pattern = 3; + repeated string pathspec = 4; + Pagination pagination = 5; +} + +message FindFilesResponse { + repeated FileMetadata files = 1; + PageInfo page_info = 2; +} + +service TreeService { + rpc ListTree(ListTreeRequest) returns (ListTreeResponse); + rpc GetTree(GetTreeRequest) returns (Tree); + rpc GetBlob(GetBlobRequest) returns (Blob); + rpc GetRawBlob(GetRawBlobRequest) returns (stream GetRawBlobResponse); + rpc GetFileMetadata(GetFileMetadataRequest) returns (FileMetadata); + rpc FindFiles(FindFilesRequest) returns (FindFilesResponse); +} diff --git a/queue/mod.rs b/queue/mod.rs new file mode 100644 index 0000000..6a17a7d --- /dev/null +++ b/queue/mod.rs @@ -0,0 +1,183 @@ +pub mod publisher; +pub mod subscriber; + +use std::sync::Arc; +use std::time::Duration; + +use async_nats::jetstream::Context; +use async_nats::jetstream::stream::{Config as StreamConfig, RetentionPolicy}; + +use crate::config::AppConfig; +use crate::error::{AppError, AppResult}; + +#[derive(Clone)] +pub struct NatsQueue { + inner: Arc, +} + +struct NatsQueueInner { + js: Context, + client: async_nats::Client, + stream_prefix: String, + ack_wait: Duration, + max_deliver: i64, +} + +pub struct StreamHandle { + pub name: String, + pub subjects: Vec, +} + +impl NatsQueue { + pub async fn connect(config: &AppConfig) -> AppResult { + let url = config.nats_url()?; + let timeout = config.nats_connection_timeout_secs()?; + let ping_interval = config.nats_ping_interval_secs()?; + let reconnect_delay = config.nats_reconnect_delay_secs()?; + let max_reconnects = config.nats_max_reconnects()?; + + let mut opts = async_nats::ConnectOptions::new() + .connection_timeout(Duration::from_secs(timeout)) + .ping_interval(Duration::from_secs(ping_interval)) + .retry_on_initial_connect() + .max_reconnects(Some(max_reconnects)) + .reconnect_delay_callback(move |_| Duration::from_secs(reconnect_delay)) + .event_callback(|event| async move { + match event { + async_nats::Event::Disconnected => { + tracing::warn!("nats disconnected"); + } + async_nats::Event::Connected => { + tracing::info!("nats connected"); + } + async_nats::Event::LameDuckMode => { + tracing::warn!("nats server entering lame duck mode"); + } + _ => {} + } + }); + + if let (Some(user), Some(pass)) = (config.nats_username()?, config.nats_password()?) { + opts = opts.user_and_password(user, pass); + } else if let Some(token) = config.nats_token()? { + opts = opts.token(token); + } + + let client = async_nats::connect_with_options(&url, opts) + .await + .map_err(|e| AppError::Config(format!("nats connect failed: {e}")))?; + + let js = async_nats::jetstream::new(client.clone()); + let stream_prefix = config.nats_stream_prefix()?; + let ack_wait = config.nats_default_ack_wait_secs()?; + let max_deliver = config.nats_default_max_deliver()?; + + tracing::info!(url = %url, "nats connected"); + + Ok(Self { + inner: Arc::new(NatsQueueInner { + js, + client, + stream_prefix, + ack_wait: Duration::from_secs(ack_wait), + max_deliver, + }), + }) + } + + pub fn client(&self) -> &async_nats::Client { + &self.inner.client + } + + pub fn js(&self) -> &Context { + &self.inner.js + } + + pub fn stream_name(&self, name: &str) -> String { + format!("{}_{}", self.inner.stream_prefix, name) + } + + pub async fn ensure_stream( + &self, + name: &str, + subjects: Vec, + ) -> AppResult { + let stream_name = self.stream_name(name); + let config = StreamConfig { + name: stream_name.clone(), + subjects, + retention: RetentionPolicy::Limits, + max_messages: 100_000, + max_age: Duration::from_secs(7 * 24 * 3600), + duplicate_window: Duration::from_secs(120), + storage: async_nats::jetstream::stream::StorageType::File, + ..Default::default() + }; + + self.inner + .js + .get_or_create_stream(config) + .await + .map_err(|e| AppError::Config(format!("ensure stream {stream_name} failed: {e}")))?; + + let subjects = self + .inner + .js + .get_stream(&stream_name) + .await + .map_err(|e| AppError::Config(format!("get stream {stream_name} failed: {e}")))? + .info() + .await + .map_err(|e| AppError::Config(format!("stream info {stream_name} failed: {e}")))? + .config + .subjects + .clone(); + + tracing::info!(stream = %stream_name, subjects = ?subjects, "stream ready"); + + Ok(StreamHandle { + name: stream_name, + subjects, + }) + } + + pub async fn ensure_ephemeral_stream( + &self, + name: &str, + subjects: Vec, + max_age_secs: u64, + ) -> AppResult { + let stream_name = self.stream_name(name); + let config = StreamConfig { + name: stream_name.clone(), + subjects, + retention: RetentionPolicy::Limits, + max_messages: 10_000, + max_age: Duration::from_secs(max_age_secs), + duplicate_window: Duration::from_secs(60), + storage: async_nats::jetstream::stream::StorageType::Memory, + ..Default::default() + }; + + self.inner + .js + .get_or_create_stream(config) + .await + .map_err(|e| AppError::Config(format!("ensure stream {stream_name} failed: {e}")))?; + + Ok(StreamHandle { + name: stream_name, + subjects: vec![], + }) + } + + pub async fn delete_stream(&self, name: &str) -> AppResult<()> { + let stream_name = self.stream_name(name); + self.inner + .js + .delete_stream(&stream_name) + .await + .map_err(|e| AppError::Config(format!("delete stream {stream_name} failed: {e}")))?; + Ok(()) + } +} diff --git a/queue/publisher.rs b/queue/publisher.rs new file mode 100644 index 0000000..1aaa328 --- /dev/null +++ b/queue/publisher.rs @@ -0,0 +1,65 @@ +use serde::Serialize; + +use crate::error::{AppError, AppResult}; + +use super::NatsQueue; + +pub struct PublishResult { + pub stream: String, + pub sequence: u64, +} + +impl NatsQueue { + pub async fn publish(&self, subject: &str, payload: &[u8]) -> AppResult { + let subject = subject.to_string(); + let ack = self + .inner + .js + .publish(subject.clone(), payload.to_vec().into()) + .await + .map_err(|e| AppError::Config(format!("publish to {subject} failed: {e}")))? + .await + .map_err(|e| AppError::Config(format!("publish ack for {subject} failed: {e}")))?; + + Ok(PublishResult { + stream: ack.stream, + sequence: ack.sequence, + }) + } + + pub async fn publish_json( + &self, + subject: &str, + payload: &T, + ) -> AppResult { + let data = serde_json::to_vec(payload)?; + self.publish(subject, &data).await + } + + pub async fn publish_with_headers( + &self, + subject: &str, + payload: &[u8], + headers: Vec<(String, String)>, + ) -> AppResult { + let subject = subject.to_string(); + let mut nats_headers = async_nats::HeaderMap::new(); + for (k, v) in headers { + nats_headers.insert(k, v); + } + + let ack = self + .inner + .js + .publish_with_headers(subject.clone(), nats_headers, payload.to_vec().into()) + .await + .map_err(|e| AppError::Config(format!("publish to {subject} failed: {e}")))? + .await + .map_err(|e| AppError::Config(format!("publish ack for {subject} failed: {e}")))?; + + Ok(PublishResult { + stream: ack.stream, + sequence: ack.sequence, + }) + } +} diff --git a/queue/subscriber.rs b/queue/subscriber.rs new file mode 100644 index 0000000..83a0c47 --- /dev/null +++ b/queue/subscriber.rs @@ -0,0 +1,202 @@ +use std::time::Duration; + +use async_nats::jetstream::consumer::AckPolicy; +use async_nats::jetstream::consumer::pull::Config as PullConfig; +use async_nats::jetstream::consumer::push::Config as PushConfig; +use async_nats::jetstream::consumer::push::Messages; +use futures_util::StreamExt; +use serde::de::DeserializeOwned; + +use crate::error::{AppError, AppResult}; + +use super::NatsQueue; + +pub struct NatsMessage { + inner: async_nats::jetstream::Message, +} + +impl NatsMessage { + pub fn subject(&self) -> &str { + self.inner.message.subject.as_str() + } + + pub fn payload(&self) -> &[u8] { + &self.inner.message.payload + } + + pub fn payload_json(&self) -> AppResult { + serde_json::from_slice(&self.inner.message.payload).map_err(AppError::from) + } + + pub fn headers(&self) -> Option<&async_nats::HeaderMap> { + self.inner.message.headers.as_ref() + } + + pub async fn ack(self) -> AppResult<()> { + self.inner + .ack() + .await + .map_err(|e| AppError::Config(format!("ack failed: {e}"))) + } + + pub async fn nack(self) -> AppResult<()> { + self.inner + .ack_with(async_nats::jetstream::AckKind::Nak(None)) + .await + .map_err(|e| AppError::Config(format!("nack failed: {e}"))) + } + + pub async fn nack_with_delay(self, delay: Duration) -> AppResult<()> { + self.inner + .ack_with(async_nats::jetstream::AckKind::Nak(Some(delay))) + .await + .map_err(|e| AppError::Config(format!("nack with delay failed: {e}"))) + } + + pub async fn ack_in_progress(&self) -> AppResult<()> { + self.inner + .ack_with(async_nats::jetstream::AckKind::Progress) + .await + .map_err(|e| AppError::Config(format!("ack in progress failed: {e}"))) + } + + pub async fn term(self) -> AppResult<()> { + self.inner + .ack_with(async_nats::jetstream::AckKind::Term) + .await + .map_err(|e| AppError::Config(format!("term failed: {e}"))) + } +} + +pub struct PullSubscription { + stream: async_nats::jetstream::stream::Stream, + consumer_name: String, +} + +impl NatsQueue { + pub async fn subscribe_broadcast( + &self, + stream_name: &str, + consumer_name: &str, + filter_subject: Option<&str>, + ) -> AppResult { + let full_stream = self.stream_name(stream_name); + let stream = self + .inner + .js + .get_stream(&full_stream) + .await + .map_err(|e| AppError::Config(format!("get stream {full_stream} failed: {e}")))?; + + let deliver_subject = format!( + "deliver.{}.{}.{}", + self.inner.stream_prefix, stream_name, consumer_name + ); + + let config = PushConfig { + durable_name: Some(consumer_name.to_string()), + deliver_subject, + deliver_group: Some(consumer_name.to_string()), + ack_policy: AckPolicy::Explicit, + ack_wait: self.inner.ack_wait, + max_deliver: self.inner.max_deliver, + filter_subject: filter_subject.unwrap_or(">").to_string(), + ..Default::default() + }; + + let consumer = stream + .get_or_create_consumer(consumer_name, config) + .await + .map_err(|e| { + AppError::Config(format!("create push consumer {consumer_name} failed: {e}")) + })?; + + let messages = consumer.messages().await.map_err(|e| { + AppError::Config(format!("subscribe broadcast {consumer_name} failed: {e}")) + })?; + + Ok(messages) + } + + pub async fn create_pull_consumer( + &self, + stream_name: &str, + consumer_name: &str, + filter_subject: Option<&str>, + max_batch: usize, + ) -> AppResult { + let full_stream = self.stream_name(stream_name); + let stream = self + .inner + .js + .get_stream(&full_stream) + .await + .map_err(|e| AppError::Config(format!("get stream {full_stream} failed: {e}")))?; + + let mut config = PullConfig { + durable_name: Some(consumer_name.to_string()), + ack_policy: AckPolicy::Explicit, + ack_wait: self.inner.ack_wait, + max_deliver: self.inner.max_deliver, + max_ack_pending: max_batch as i64, + ..Default::default() + }; + + if let Some(subject) = filter_subject { + config.filter_subject = subject.to_string(); + } + + stream + .get_or_create_consumer(consumer_name, config) + .await + .map_err(|e| { + AppError::Config(format!("create pull consumer {consumer_name} failed: {e}")) + })?; + + Ok(PullSubscription { + stream, + consumer_name: consumer_name.to_string(), + }) + } + + pub async fn fetch( + &self, + subscription: &PullSubscription, + batch_size: usize, + ) -> AppResult> { + let consumer = subscription + .stream + .get_consumer(&subscription.consumer_name) + .await + .map_err(|e| AppError::Config(format!("get consumer failed: {e}")))?; + + let mut messages = consumer + .fetch() + .max_messages(batch_size) + .messages() + .await + .map_err(|e| AppError::Config(format!("fetch failed: {e}")))?; + + let mut result = Vec::with_capacity(batch_size); + while let Some(msg) = messages.next().await { + match msg { + Ok(m) => result.push(NatsMessage { inner: m }), + Err(e) => { + tracing::warn!(error = %e, "fetch message error"); + break; + } + } + } + Ok(result) + } + + pub async fn subscribe_ephemeral(&self, subject: String) -> AppResult { + let sub = self + .inner + .client + .subscribe(subject.clone()) + .await + .map_err(|e| AppError::Config(format!("subscribe ephemeral {subject} failed: {e}")))?; + Ok(sub) + } +} diff --git a/service/auth/captcha.rs b/service/auth/captcha.rs new file mode 100644 index 0000000..d1a39d0 --- /dev/null +++ b/service/auth/captcha.rs @@ -0,0 +1,85 @@ +use crate::session::Session; +use serde::{Deserialize, Serialize}; + +use crate::error::AppError; +use crate::service::AuthService; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CaptchaQuery { + pub w: u32, + pub h: u32, + pub dark: bool, + pub rsa: bool, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct CaptchaResponse { + pub base64: String, + pub rsa: Option, + pub req: CaptchaQuery, +} + +impl AuthService { + const CAPTCHA_KEY: &'static str = "captcha"; + const CAPTCHA_LENGTH: usize = 4; + const CAPTCHA_MIN_WIDTH: u32 = 80; + const CAPTCHA_MAX_WIDTH: u32 = 400; + const CAPTCHA_MIN_HEIGHT: u32 = 30; + const CAPTCHA_MAX_HEIGHT: u32 = 200; + + pub async fn auth_captcha( + &self, + context: &Session, + query: CaptchaQuery, + ) -> Result { + let CaptchaQuery { w, h, dark, rsa } = query; + if !(Self::CAPTCHA_MIN_WIDTH..=Self::CAPTCHA_MAX_WIDTH).contains(&w) + || !(Self::CAPTCHA_MIN_HEIGHT..=Self::CAPTCHA_MAX_HEIGHT).contains(&h) + { + return Err(AppError::BadRequest("invalid captcha size".into())); + } + + let captcha = captcha_rs::CaptchaBuilder::new() + .width(w) + .height(h) + .dark_mode(dark) + .length(Self::CAPTCHA_LENGTH) + .build(); + + let base64 = captcha.to_base64(); + let text = captcha.text; + context + .insert(Self::CAPTCHA_KEY, text) + .map_err(|_| AppError::InternalServerError("session insert failed".into()))?; + + Ok(CaptchaResponse { + base64, + rsa: if rsa { + Some(self.auth_rsa(context).await?) + } else { + None + }, + req: CaptchaQuery { w, h, dark, rsa }, + }) + } + + pub async fn auth_check_captcha( + &self, + context: &Session, + captcha: String, + ) -> Result<(), AppError> { + let text = context + .get::(Self::CAPTCHA_KEY) + .map_err(|_| AppError::CaptchaError)? + .ok_or(AppError::CaptchaError)?; + if !constant_time_eq(&text.to_lowercase(), &captcha.to_lowercase()) { + context.remove(Self::CAPTCHA_KEY); + tracing::warn!("Captcha verification failed"); + return Err(AppError::CaptchaError); + } + context.remove(Self::CAPTCHA_KEY); + Ok(()) + } +} + +use crate::service::util::constant_time_eq; diff --git a/service/auth/email.rs b/service/auth/email.rs new file mode 100644 index 0000000..c8ddf0f --- /dev/null +++ b/service/auth/email.rs @@ -0,0 +1,202 @@ +use argon2::{Argon2, PasswordHash, password_hash::PasswordVerifier}; +use serde::{Deserialize, Serialize}; + +use sqlx::Row; +use std::time::Duration; + +use crate::error::AppError; +use crate::models::users::UserMail; +use crate::pb::email::{EmailAddress, SendEmailRequest}; +use crate::service::AuthService; +use crate::session::Session; + +#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] +pub struct EmailChangeRequest { + pub new_email: String, + pub password: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] +pub struct EmailVerifyRequest { + pub token: String, +} + +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +pub struct EmailResponse { + pub email: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] +struct PendingEmailChange { + user_uid: uuid::Uuid, + new_email: String, +} + +impl AuthService { + const EMAIL_CHANGE_PREFIX: &'static str = "auth:email_change:"; + const EMAIL_CHANGE_TTL_SECS: u64 = 60 * 60; + + pub async fn auth_get_email(&self, ctx: &Session) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let email = sqlx::query_as::<_, UserMail>( + "SELECT id, user_id, email, is_primary, is_verified, \ + verification_token_hash, verified_at, created_at, updated_at \ + FROM user_mail WHERE user_id = $1 AND is_verified = true", + ) + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + Ok(EmailResponse { + email: email.map(|e| e.email), + }) + } + + pub async fn auth_email_change_request( + &self, + ctx: &Session, + params: EmailChangeRequest, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let new_email = params.new_email.trim().to_lowercase(); + if new_email.is_empty() { + return Err(AppError::BadRequest("email is required".into())); + } + let password = self.auth_rsa_decode(ctx, params.password).await?; + + let row = sqlx::query("SELECT password_hash FROM user_password WHERE user_id = $1") + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::UserNotFound)?; + let hash: String = row.try_get("password_hash").map_err(AppError::Database)?; + + let password_hash = PasswordHash::new(&hash).map_err(|_| AppError::UserNotFound)?; + Argon2::default() + .verify_password(password.as_bytes(), &password_hash) + .map_err(|_| AppError::InvalidPassword)?; + + let existing = sqlx::query_as::<_, UserMail>( + "SELECT id, user_id, email, is_primary, is_verified, \ + verification_token_hash, verified_at, created_at, updated_at \ + FROM user_mail WHERE lower(email) = lower($1) AND is_verified = true", + ) + .bind(&new_email) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + if existing.is_some() { + return Err(AppError::EmailExists); + } + + let token = super::generate_token("emc"); + let cache_key = format!("{}{}", Self::EMAIL_CHANGE_PREFIX, token); + self.ctx + .cache + .set( + &cache_key, + &PendingEmailChange { + user_uid, + new_email: new_email.clone(), + }, + Some(Duration::from_secs(Self::EMAIL_CHANGE_TTL_SECS)), + ) + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + let domain = self.ctx.config.main_domain()?; + let verify_link = format!("{}/auth/verify-email?token={}", domain, token); + + let mut mail = self + .ctx + .registry + .get_email_client() + .ok_or(AppError::Config("mail service not available".into()))?; + mail.send_email(tonic::Request::new(SendEmailRequest { + to: vec![EmailAddress { + email: new_email.clone(), + name: String::new(), + }], + subject: "Confirm Email Change".into(), + text_body: format!( + "You requested to change your email address.\n\n\ + Confirm the change here:\n\n{}\n\n\ + If you did not request this change, ignore this email.", + verify_link + ), + ..Default::default() + })) + .await + .map_err(|e| { + tracing::error!(error = %e, new_email = %new_email, "Failed to send email change verification"); + AppError::InternalServerError(e.to_string()) + })?; + + tracing::info!(new_email = %new_email, user_uid = %user_uid, "Email change verification sent"); + Ok(()) + } + + pub async fn auth_email_verify(&self, params: EmailVerifyRequest) -> Result<(), AppError> { + if params.token.is_empty() { + return Err(AppError::BadRequest( + "missing email verification token".into(), + )); + } + let cache_key = format!("{}{}", Self::EMAIL_CHANGE_PREFIX, params.token); + let pending = + self.ctx + .cache + .get::(&cache_key) + .ok_or(AppError::NotFound( + "invalid or expired email verification token".into(), + ))?; + + let existing = sqlx::query_as::<_, UserMail>( + "SELECT id, user_id, email, is_primary, is_verified, \ + verification_token_hash, verified_at, created_at, updated_at \ + FROM user_mail WHERE lower(email) = lower($1) AND is_verified = true", + ) + .bind(&pending.new_email) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + if existing.is_some() { + return Err(AppError::EmailExists); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + + sqlx::query("UPDATE user_mail SET is_verified = false, updated_at = $1 WHERE user_id = $2") + .bind(now) + .bind(pending.user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO user_mail (id, user_id, email, is_primary, is_verified, created_at, updated_at) \ + VALUES ($1, $2, $3, true, true, $4, $4)", + ) + .bind(uuid::Uuid::now_v7()) + .bind(pending.user_uid) + .bind(&pending.new_email) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + + let _ = self.ctx.cache.delete(&cache_key); + tracing::info!(new_email = %pending.new_email, user_uid = %pending.user_uid, "Email changed"); + Ok(()) + } +} diff --git a/service/auth/login.rs b/service/auth/login.rs new file mode 100644 index 0000000..9e2e97c --- /dev/null +++ b/service/auth/login.rs @@ -0,0 +1,196 @@ +use argon2::{ + Argon2, PasswordHash, + password_hash::{PasswordHasher, PasswordVerifier}, +}; +use serde::{Deserialize, Serialize}; +use sqlx::Row; + +use crate::error::AppError; +use crate::models::users::User; +use crate::service::AuthService; +use crate::session::Session; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct LoginParams { + pub username: String, + pub password: String, + pub captcha: String, + pub totp_code: Option, +} + +impl AuthService { + pub const TOTP_KEY: &'static str = "totp_key"; + const TOTP_ATTEMPTS_PREFIX: &'static str = "auth:totp_attempts:"; + const TOTP_MAX_ATTEMPTS: u64 = 5; + const TOTP_PENDING_TTL_SECS: u64 = 600; + + #[tracing::instrument(skip(self, params, context), fields(username = %params.username))] + pub async fn auth_login(&self, params: LoginParams, context: Session) -> Result<(), AppError> { + let login = params.username.trim().to_string(); + let totp_pending = context + .get::(Self::TOTP_KEY) + .ok() + .flatten() + .is_some(); + if !totp_pending { + self.auth_check_captcha(&context, params.captcha).await?; + } + let password = self.auth_rsa_decode(&context, params.password).await?; + + let user = match self.auth_find_user_by_username(&login).await { + Ok(user) => user, + Err(_) => match self.auth_find_user_by_email(&login).await { + Ok(user) => user, + Err(_) => { + let _ = Argon2::default().hash_password( + password.as_bytes(), + &argon2::password_hash::SaltString::generate(&mut rand::thread_rng()), + ); + tracing::warn!(username = %login, "Login: user not found"); + return Err(AppError::UserNotFound); + } + }, + }; + + let row = sqlx::query( + "SELECT user_id, password_hash, password_algo, password_salt, \ + must_change_password, password_updated_at, created_at, updated_at \ + FROM user_password WHERE user_id = $1", + ) + .bind(user.id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let row = row.ok_or(AppError::UserNotFound)?; + let hash: String = row.try_get("password_hash").map_err(AppError::Database)?; + + let password_hash = PasswordHash::new(&hash).map_err(|_| AppError::UserNotFound)?; + if Argon2::default() + .verify_password(password.as_bytes(), &password_hash) + .is_err() + { + tracing::warn!(username = %login, "Login: invalid password"); + return Err(AppError::UserNotFound); + } + + let two_factor_enabled = self.auth_2fa_status_by_uid(user.id).await?.is_enabled; + if two_factor_enabled { + if let Some(totp_session_key) = context.get::(Self::TOTP_KEY).ok().flatten() { + let Some(ref totp_code) = params.totp_code else { + return Err(AppError::InvalidTwoFactorCode); + }; + let attempts_key = format!("{}{}", Self::TOTP_ATTEMPTS_PREFIX, totp_session_key); + let attempts = self.ctx.cache.get::(&attempts_key).unwrap_or(0); + if attempts >= Self::TOTP_MAX_ATTEMPTS { + context.remove(Self::TOTP_KEY); + let _ = self.ctx.cache.delete(&totp_session_key); + let _ = self.ctx.cache.delete(&attempts_key); + return Err(AppError::InvalidTwoFactorCode); + } + + if !self + .auth_2fa_verify_login(&context, user.id, totp_code) + .await? + { + let next_attempts = attempts + 1; + if next_attempts >= Self::TOTP_MAX_ATTEMPTS { + context.remove(Self::TOTP_KEY); + let _ = self.ctx.cache.delete(&totp_session_key); + let _ = self.ctx.cache.delete(&attempts_key); + } else { + self.ctx + .cache + .set( + &attempts_key, + &next_attempts, + Some(std::time::Duration::from_secs(Self::TOTP_PENDING_TTL_SECS)), + ) + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + } + return Err(AppError::InvalidTwoFactorCode); + } + let _ = self.ctx.cache.delete(&attempts_key); + } else { + let totp_session_key = uuid::Uuid::new_v4().to_string(); + context + .insert(Self::TOTP_KEY, totp_session_key.clone()) + .map_err(|_| AppError::InternalServerError("session insert failed".into()))?; + self.ctx + .cache + .set( + &totp_session_key, + &user.id, + Some(std::time::Duration::from_secs(Self::TOTP_PENDING_TTL_SECS)), + ) + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + tracing::info!(username = %login, "Login 2FA triggered"); + return Err(AppError::TwoFactorRequired); + } + } else if let Some(totp_session_key) = context.get::(Self::TOTP_KEY).ok().flatten() + { + context.remove(Self::TOTP_KEY); + let attempts_key = format!("{}{}", Self::TOTP_ATTEMPTS_PREFIX, totp_session_key); + let _ = self.ctx.cache.delete(&totp_session_key); + let _ = self.ctx.cache.delete(&attempts_key); + } + + sqlx::query("UPDATE \"user\" SET last_login_at = $1, updated_at = $1 WHERE id = $2") + .bind(chrono::Utc::now()) + .bind(user.id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + context.renew(); + context.set_user(user.id); + context.remove(Self::RSA_PRIVATE_KEY); + context.remove(Self::RSA_PUBLIC_KEY); + tracing::info!(user_uid = %user.id, username = %user.username, "User logged in"); + Ok(()) + } + + pub(crate) async fn auth_find_user_by_username( + &self, + username: &str, + ) -> Result { + sqlx::query_as::<_, User>( + "SELECT id, username, display_name, avatar_url, bio, status, role, visibility, \ + is_active, is_bot, last_login_at, created_at, updated_at, deleted_at \ + FROM \"user\" WHERE lower(username) = lower($1) AND is_active = true AND status = 'active' AND deleted_at IS NULL", + ) + .bind(username) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::UserNotFound) + } + + pub(crate) async fn auth_find_user_by_email(&self, email: &str) -> Result { + sqlx::query_as::<_, User>( + "SELECT u.id, u.username, u.display_name, u.avatar_url, u.bio, u.status, u.role, \ + u.visibility, u.is_active, u.is_bot, u.last_login_at, u.created_at, u.updated_at, u.deleted_at \ + FROM \"user\" u \ + INNER JOIN user_mail e ON e.user_id = u.id \ + WHERE lower(e.email) = lower($1) AND e.is_verified = true AND u.is_active = true AND u.status = 'active' AND u.deleted_at IS NULL", + ) + .bind(email) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::UserNotFound) + } + + pub(crate) async fn auth_find_user_by_uid(&self, uid: uuid::Uuid) -> Result { + sqlx::query_as::<_, User>( + "SELECT id, username, display_name, avatar_url, bio, status, role, visibility, \ + is_active, is_bot, last_login_at, created_at, updated_at, deleted_at \ + FROM \"user\" WHERE id = $1 AND is_active = true AND status = 'active' AND deleted_at IS NULL", + ) + .bind(uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::UserNotFound) + } +} diff --git a/service/auth/logout.rs b/service/auth/logout.rs new file mode 100644 index 0000000..1c73b4e --- /dev/null +++ b/service/auth/logout.rs @@ -0,0 +1,14 @@ +use crate::error::AppError; +use crate::service::AuthService; +use crate::session::Session; + +impl AuthService { + pub async fn auth_logout(&self, context: &Session) -> Result<(), AppError> { + if let Some(user_uid) = context.user() { + tracing::info!(user_uid = %user_uid, "User logged out"); + } + context.clear_user(); + context.clear(); + Ok(()) + } +} diff --git a/service/auth/me.rs b/service/auth/me.rs new file mode 100644 index 0000000..43f9045 --- /dev/null +++ b/service/auth/me.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::AppError; +use crate::service::AuthService; +use crate::session::Session; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct ContextMe { + pub id: uuid::Uuid, + pub username: String, + pub display_name: Option, + pub avatar_url: Option, + pub has_unread_notifications: u64, + pub language: String, + pub timezone: String, +} + +impl AuthService { + pub async fn auth_me(&self, ctx: Session) -> Result { + let user_id = ctx.user().ok_or(AppError::Unauthorized)?; + let user = self.auth_find_user_by_uid(user_id).await?; + let profile = + crate::models::users::UserProfile::find_by_user_id(self.ctx.db.reader(), user_id) + .await + .map_err(AppError::Database) + .ok() + .flatten(); + + Ok(ContextMe { + id: user.id, + username: user.username, + display_name: user.display_name.filter(|n| !n.is_empty()), + avatar_url: user.avatar_url.filter(|u| !u.is_empty()), + has_unread_notifications: 0, + language: profile + .as_ref() + .and_then(|p| p.language.clone()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| "en".to_string()), + timezone: profile + .as_ref() + .and_then(|p| p.timezone.clone()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| "UTC".to_string()), + }) + } +} diff --git a/service/auth/mod.rs b/service/auth/mod.rs new file mode 100644 index 0000000..7e2db30 --- /dev/null +++ b/service/auth/mod.rs @@ -0,0 +1,25 @@ +pub mod captcha; +pub mod email; +pub mod login; +pub mod logout; +pub mod me; +pub mod register; +pub mod reset_pass; +pub mod rsa; +pub mod totp; + +pub(crate) fn generate_token(prefix: &str) -> String { + let mut rng = rand::thread_rng(); + use rand::Rng; + let chars: String = (0..64) + .map(|_| { + let idx = rng.gen_range(0..62); + const CHARSET: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + CHARSET[idx] as char + }) + .collect(); + format!("{}_{}", prefix, chars) +} + +// constant_time_eq is provided by crate::service::util diff --git a/service/auth/register.rs b/service/auth/register.rs new file mode 100644 index 0000000..7b9a809 --- /dev/null +++ b/service/auth/register.rs @@ -0,0 +1,239 @@ +use argon2::password_hash::SaltString; +use argon2::{Argon2, password_hash::PasswordHasher}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +use crate::error::AppError; +use crate::models::users::User; +use crate::pb::email::{EmailAddress, SendEmailRequest}; +use crate::service::AuthService; +use crate::session::Session; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct RegisterParams { + pub username: String, + pub email: String, + pub password: String, + pub captcha: String, + pub email_code: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct RegisterEmailCodeParams { + pub email: String, + pub captcha: String, +} + +#[derive(Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct RegisterEmailCodeResponse { + pub expires_in_secs: u64, +} + +impl AuthService { + const REGISTER_EMAIL_CODE_PREFIX: &'static str = "auth:register_email:"; + const REGISTER_EMAIL_CODE_TTL_SECS: u64 = 600; + const REGISTER_EMAIL_CODE_COOLDOWN_SECS: u64 = 60; + + pub async fn auth_register_email_code( + &self, + params: RegisterEmailCodeParams, + context: &Session, + ) -> Result { + self.auth_check_captcha(context, params.captcha).await?; + let email = params.email.trim().to_lowercase(); + if email.is_empty() { + return Err(AppError::BadRequest("email is required".into())); + } + + if self.auth_verified_email_exists(&email).await? { + return Err(AppError::EmailExists); + } + + let cooldown_key = format!("{}cooldown:{}", Self::REGISTER_EMAIL_CODE_PREFIX, email); + if self.ctx.cache.exists(&cooldown_key) { + return Err(AppError::BadRequest( + "verification code was sent recently; please try again later".into(), + )); + } + + let code = Self::generate_register_email_code(); + let cache_key = Self::register_email_code_key(&email); + self.ctx + .cache + .set( + &cache_key, + &code, + Some(Duration::from_secs(Self::REGISTER_EMAIL_CODE_TTL_SECS)), + ) + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + self.ctx + .cache + .set( + &cooldown_key, + &true, + Some(Duration::from_secs(Self::REGISTER_EMAIL_CODE_COOLDOWN_SECS)), + ) + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + let mut mail = self + .ctx + .registry + .get_email_client() + .ok_or(AppError::Config("mail service not available".into()))?; + mail.send_email(tonic::Request::new(SendEmailRequest { + to: vec![EmailAddress { + email: email.clone(), + name: String::new(), + }], + subject: "Register Email Verification".into(), + text_body: format!( + "Your registration verification code is: {}\n\nThis code expires in 10 minutes.", + code + ), + ..Default::default() + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + Ok(RegisterEmailCodeResponse { + expires_in_secs: Self::REGISTER_EMAIL_CODE_TTL_SECS, + }) + } + + fn auth_check_register_email_code(&self, email: &str, code: &str) -> Result<(), AppError> { + let cache_key = Self::register_email_code_key(email); + let stored = self + .ctx + .cache + .get::(&cache_key) + .ok_or(AppError::InvalidEmailCode)?; + if !crate::service::util::constant_time_eq(stored.trim(), code.trim()) { + return Err(AppError::InvalidEmailCode); + } + let _ = self.ctx.cache.delete(&cache_key); + Ok(()) + } + + fn register_email_code_key(email: &str) -> String { + format!( + "{}{}", + Self::REGISTER_EMAIL_CODE_PREFIX, + email.trim().to_lowercase() + ) + } + + fn generate_register_email_code() -> String { + let mut rng = rand::thread_rng(); + format!("{:06}", rng.gen_range(0..1_000_000)) + } + + async fn auth_username_exists(&self, username: &str) -> Result { + sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM \"user\" WHERE lower(username) = lower($1) AND deleted_at IS NULL)", + ) + .bind(username) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + async fn auth_verified_email_exists(&self, email: &str) -> Result { + sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM user_mail WHERE lower(email) = lower($1) AND is_verified = true)", + ) + .bind(email) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + #[tracing::instrument(skip(self, params, context), fields(username = %params.username))] + pub async fn auth_register( + &self, + params: RegisterParams, + context: &Session, + ) -> Result { + self.auth_check_captcha(context, params.captcha).await?; + let username = params.username.trim().to_string(); + let email = params.email.trim().to_lowercase(); + if username.is_empty() || email.is_empty() { + return Err(AppError::BadRequest( + "username and email are required".into(), + )); + } + let password = self.auth_rsa_decode(context, params.password).await?; + crate::service::util::validate_password_strength(&password)?; + + let username_exists = self.auth_username_exists(&username).await?; + let email_exists = self.auth_verified_email_exists(&email).await?; + if username_exists || email_exists { + return Err(AppError::AccountAlreadyExists); + } + + self.auth_check_register_email_code(&email, ¶ms.email_code)?; + + let user_id = uuid::Uuid::now_v7(); + let now = chrono::Utc::now(); + let salt = SaltString::generate(&mut rand::thread_rng()); + let password_hash = Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|e| AppError::PasswordHashError(e.to_string()))? + .to_string(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + + let user = sqlx::query_as::<_, User>( + "INSERT INTO \"user\" \ + (id, username, display_name, status, role, visibility, is_active, is_bot, \ + last_login_at, created_at, updated_at) \ + VALUES ($1, $2, $2, 'active', 'user', 'public', true, false, NULL, $3, $3) \ + RETURNING id, username, display_name, avatar_url, bio, status, role, visibility, \ + is_active, is_bot, last_login_at, created_at, updated_at, deleted_at", + ) + .bind(user_id) + .bind(&username) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO user_mail (id, user_id, email, is_primary, is_verified, created_at, updated_at) \ + VALUES ($1, $2, $3, true, true, $4, $4)", + ) + .bind(uuid::Uuid::now_v7()) + .bind(user_id) + .bind(&email) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO user_password (user_id, password_hash, password_algo, password_salt, \ + must_change_password, password_updated_at, created_at, updated_at) \ + VALUES ($1, $2, 'argon2id', '', false, $3, $3, $3)", + ) + .bind(user_id) + .bind(&password_hash) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + + context.set_user(user_id); + context.remove(Self::RSA_PRIVATE_KEY); + context.remove(Self::RSA_PUBLIC_KEY); + tracing::info!(user_uid = %user_id, username = %user.username, "User registered"); + Ok(user) + } +} diff --git a/service/auth/reset_pass.rs b/service/auth/reset_pass.rs new file mode 100644 index 0000000..1034226 --- /dev/null +++ b/service/auth/reset_pass.rs @@ -0,0 +1,187 @@ +use argon2::{Argon2, PasswordHasher, password_hash::SaltString}; +use chrono::{Duration, Utc}; +use serde::{Deserialize, Serialize}; +use std::time::Duration as StdDuration; + +use crate::error::AppError; +use crate::pb::email::{EmailAddress, SendEmailRequest}; +use crate::service::AuthService; +use crate::session::Session; + +#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] +pub struct ResetPasswordRequest { + pub email: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] +pub struct ResetPasswordVerifyParams { + pub token: String, + pub password: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +struct PendingResetPassword { + user_uid: uuid::Uuid, + created_at: chrono::DateTime, +} + +impl AuthService { + const RESET_PASS_PREFIX: &'static str = "auth:reset_pass:"; + const RESET_PASS_EXPIRY_HOURS: i64 = 1; + const RESET_PASS_EXPIRY_SECS: u64 = 60 * 60; + const RESET_PASS_COOLDOWN_SECS: u64 = 60; // 60 seconds between requests + const RESET_PASS_DAILY_LIMIT: u64 = 5; // Max 5 requests per day + const RESET_PASS_DAILY_SECS: u64 = 24 * 60 * 60; // 24 hours + + pub async fn auth_reset_password_request( + &self, + params: ResetPasswordRequest, + ) -> Result<(), AppError> { + let email = params.email.trim().to_lowercase(); + + // Rate limiting: check cooldown + let cooldown_key = format!("{}cooldown:{}", Self::RESET_PASS_PREFIX, email); + if self.ctx.cache.exists(&cooldown_key) { + tracing::warn!(email = %email, "Password reset request rate limited (cooldown)"); + return Ok(()); // Don't reveal if email exists + } + + // Rate limiting: check daily limit + let daily_key = format!("{}daily:{}", Self::RESET_PASS_PREFIX, email); + let daily_count: u64 = self.ctx.cache.get(&daily_key).unwrap_or(0); + if daily_count >= Self::RESET_PASS_DAILY_LIMIT { + tracing::warn!(email = %email, count = daily_count, "Password reset request rate limited (daily limit)"); + return Ok(()); // Don't reveal if email exists + } + + let user = self.auth_find_user_by_email(&email).await.ok(); + + if let Some(user) = user { + let token = super::generate_token("rst"); + let cache_key = format!("{}{}", Self::RESET_PASS_PREFIX, token); + let now = chrono::Utc::now(); + + if let Err(e) = self.ctx.cache.set( + &cache_key, + &PendingResetPassword { + user_uid: user.id, + created_at: now, + }, + Some(StdDuration::from_secs(Self::RESET_PASS_EXPIRY_SECS)), + ) { + tracing::error!(error = %e, user_uid = %user.id, "Failed to cache reset token"); + return Ok(()); + } + + // Set cooldown + if let Err(e) = self.ctx.cache.set( + &cooldown_key, + &true, + Some(StdDuration::from_secs(Self::RESET_PASS_COOLDOWN_SECS)), + ) { + tracing::warn!(error = %e, "Failed to set cooldown"); + } + + // Increment daily counter + let new_count = daily_count + 1; + if let Err(e) = self.ctx.cache.set( + &daily_key, + &new_count, + Some(StdDuration::from_secs(Self::RESET_PASS_DAILY_SECS)), + ) { + tracing::warn!(error = %e, "Failed to increment daily counter"); + } + + let domain = match self.ctx.config.main_domain() { + Ok(d) => d, + Err(e) => { + tracing::error!(error = %e, "Domain not configured for password reset"); + return Ok(()); + } + }; + let reset_link = format!("{}/auth/reset-password?token={}", domain, token); + + let mut mail = match self.ctx.registry.get_email_client() { + Some(c) => c, + None => { + tracing::error!("mail service not available"); + return Ok(()); + } + }; + if let Err(e) = mail + .send_email(tonic::Request::new(SendEmailRequest { + to: vec![EmailAddress { + email: email.clone(), + name: String::new(), + }], + subject: "Reset Your Password".into(), + text_body: format!( + "You requested to reset your password.\n\n\ + Reset your password here:\n\n{}\n\n\ + If you did not request this, ignore this email.", + reset_link + ), + ..Default::default() + })) + .await + { + tracing::error!(error = %e, email = %email, "Failed to send password reset email"); + } + + tracing::info!(email = %email, user_uid = %user.id, "Password reset email sent"); + } + + Ok(()) + } + + pub async fn auth_reset_password_verify( + &self, + context: &Session, + params: ResetPasswordVerifyParams, + ) -> Result<(), AppError> { + if params.token.is_empty() { + return Err(AppError::InvalidResetToken); + } + + let cache_key = format!("{}{}", Self::RESET_PASS_PREFIX, params.token); + let pending = self + .ctx + .cache + .get::(&cache_key) + .ok_or(AppError::InvalidResetToken)?; + + if Utc::now() - pending.created_at > Duration::hours(Self::RESET_PASS_EXPIRY_HOURS) { + let _ = self.ctx.cache.delete(&cache_key); + return Err(AppError::ResetTokenExpired); + } + + let password = self.auth_rsa_decode(context, params.password).await?; + crate::service::util::validate_password_strength(&password)?; + let _ = self.ctx.cache.delete(&cache_key); + + let salt = SaltString::generate(&mut rand::thread_rng()); + let password_hash = Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|e| AppError::PasswordHashError(e.to_string()))? + .to_string(); + + let now = chrono::Utc::now(); + let result = sqlx::query( + "UPDATE user_password SET password_hash = $1, password_updated_at = $2, updated_at = $2 \ + WHERE user_id = $3", + ) + .bind(&password_hash) + .bind(now) + .bind(pending.user_uid) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + if result.rows_affected() == 0 { + return Err(AppError::InvalidResetToken); + } + + tracing::info!(user_uid = %pending.user_uid, "Password reset successfully"); + Ok(()) + } +} diff --git a/service/auth/rsa.rs b/service/auth/rsa.rs new file mode 100644 index 0000000..230cafd --- /dev/null +++ b/service/auth/rsa.rs @@ -0,0 +1,145 @@ +use base64::Engine; +use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce, aead::Aead}; +use hkdf::Hkdf; +use rsa::{ + Oaep, RsaPrivateKey, RsaPublicKey, + pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey, EncodeRsaPublicKey}, +}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; + +use crate::error::AppError; +use crate::service::AuthService; +use crate::session::Session; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct RsaResponse { + pub public_key: String, +} + +impl AuthService { + pub const RSA_PRIVATE_KEY: &'static str = "rsa:private"; + pub const RSA_PUBLIC_KEY: &'static str = "rsa:public"; + const RSA_BIT_SIZE: usize = 2048; + + fn derive_rsa_encryption_key(&self) -> Result<[u8; 32], AppError> { + let secret = self + .ctx + .config + .env + .get("APP_SESSION_SECRET") + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| AppError::Config("APP_SESSION_SECRET is required".into()))?; + let hk = Hkdf::::new(Some(b"rsa-session-encryption"), secret.as_bytes()); + let mut okm = [0u8; 32]; + hk.expand(b"rsa-private-key-aead", &mut okm) + .map_err(|_| AppError::RsaGenerationError)?; + Ok(okm) + } + + fn encrypt_rsa_key(&self, plaintext: &str) -> Result { + let key = self.derive_rsa_encryption_key()?; + let cipher = ChaCha20Poly1305::new_from_slice(&key) + .expect("32-byte key is valid for ChaCha20Poly1305"); + let nonce_bytes: [u8; 12] = rand::random(); + let nonce = Nonce::from(nonce_bytes); + let ciphertext = cipher + .encrypt(&nonce, plaintext.as_bytes()) + .map_err(|_| AppError::RsaGenerationError)?; + let mut combined = nonce_bytes.to_vec(); + combined.extend_from_slice(&ciphertext); + Ok(base64::engine::general_purpose::STANDARD.encode(&combined)) + } + + fn decrypt_rsa_key(&self, encrypted: &str) -> Result { + let key = self.derive_rsa_encryption_key()?; + let cipher = ChaCha20Poly1305::new_from_slice(&key) + .expect("32-byte key is valid for ChaCha20Poly1305"); + let combined = base64::engine::general_purpose::STANDARD + .decode(encrypted) + .map_err(|_| AppError::RsaDecodeError)?; + if combined.len() < 12 { + return Err(AppError::RsaDecodeError); + } + let mut nonce_bytes = [0u8; 12]; + nonce_bytes.copy_from_slice(&combined[..12]); + let nonce = Nonce::from(nonce_bytes); + let plaintext = cipher + .decrypt(&nonce, &combined[12..]) + .map_err(|_| AppError::RsaDecodeError)?; + String::from_utf8(plaintext).map_err(|_| AppError::RsaDecodeError) + } + + pub async fn auth_rsa(&self, context: &Session) -> Result { + if context + .get::(Self::RSA_PRIVATE_KEY) + .ok() + .flatten() + .is_some() + && context + .get::(Self::RSA_PUBLIC_KEY) + .ok() + .flatten() + .is_some() + { + let public_key = context + .get::(Self::RSA_PUBLIC_KEY) + .ok() + .flatten() + .expect("checked above"); + return Ok(RsaResponse { public_key }); + } + + let mut rng = rand::thread_rng(); + let priv_key = RsaPrivateKey::new(&mut rng, Self::RSA_BIT_SIZE).map_err(|_| { + tracing::error!("RSA key generation failed"); + AppError::RsaGenerationError + })?; + let pub_key = RsaPublicKey::from(&priv_key); + let priv_pem = priv_key + .to_pkcs1_pem(Default::default()) + .map_err(|_| AppError::RsaGenerationError)? + .to_string(); + let public_key = pub_key + .to_pkcs1_pem(Default::default()) + .map_err(|_| AppError::RsaGenerationError)? + .to_string(); + + context + .insert(Self::RSA_PRIVATE_KEY, self.encrypt_rsa_key(&priv_pem)?) + .map_err(|_| AppError::RsaGenerationError)?; + context + .insert(Self::RSA_PUBLIC_KEY, public_key.clone()) + .map_err(|_| AppError::RsaGenerationError)?; + + Ok(RsaResponse { public_key }) + } + + pub async fn auth_rsa_decode( + &self, + context: &Session, + data: String, + ) -> Result { + let encrypted_priv = context + .get::(Self::RSA_PRIVATE_KEY) + .map_err(|_| AppError::RsaDecodeError)? + .ok_or(AppError::RsaDecodeError)?; + let priv_pem = self.decrypt_rsa_key(&encrypted_priv)?; + + let priv_key = RsaPrivateKey::from_pkcs1_pem(&priv_pem).map_err(|_| { + tracing::warn!("RSA decode failed: invalid private key"); + AppError::RsaDecodeError + })?; + let cipher = base64::engine::general_purpose::STANDARD + .decode(&data) + .map_err(|_| AppError::RsaDecodeError)?; + let decrypted = priv_key + .decrypt(Oaep::new::(), &cipher) + .map_err(|_| { + tracing::warn!("RSA decrypt failed"); + AppError::RsaDecodeError + })?; + Ok(String::from_utf8_lossy(&decrypted).to_string()) + } +} diff --git a/service/auth/totp.rs b/service/auth/totp.rs new file mode 100644 index 0000000..47cf8f6 --- /dev/null +++ b/service/auth/totp.rs @@ -0,0 +1,413 @@ +use argon2::{Argon2, PasswordHash, password_hash::PasswordVerifier}; +use hmac::{Hmac, Mac}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use sha1::Sha1; +use sha2::Sha256; +use sqlx::Row; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::users::User2Fa; +use crate::service::AuthService; +use crate::session::Session; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct Enable2FAResponse { + pub secret: String, + pub qr_code: String, + pub backup_codes: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct Verify2FAParams { + pub code: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct Disable2FAParams { + pub code: String, + pub password: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct Get2FAStatusResponse { + pub is_enabled: bool, + pub method: Option, + pub has_backup_codes: bool, +} + +impl AuthService { + pub async fn auth_2fa_enable(&self, context: &Session) -> Result { + let user_uid = context.user().ok_or(AppError::Unauthorized)?; + let user = self.auth_find_user_by_uid(user_uid).await?; + + let existing = self.find_2fa(user_uid).await?; + if existing.as_ref().is_some_and(|f| f.enabled) { + return Err(AppError::TwoFactorAlreadyEnabled); + } + + let secret = Self::generate_totp_secret(); + let backup_codes = Self::generate_backup_codes(10); + let qr_code = format!( + "otpauth://totp/AppKS:{}?secret={}&issuer=AppKS", + user.username, secret + ); + let now = chrono::Utc::now(); + let hashed_backup_codes = self.hash_backup_codes(&backup_codes)?.join("."); + + if existing.is_some() { + sqlx::query( + "UPDATE user_2fa SET secret = $1, backup_codes = $2, enabled = false, updated_at = $3 \ + WHERE user_id = $4", + ) + .bind(&secret) + .bind(&hashed_backup_codes) + .bind(now) + .bind(user_uid) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + } else { + sqlx::query( + "INSERT INTO user_2fa (user_id, secret, backup_codes, enabled, created_at, updated_at) \ + VALUES ($1, $2, $3, false, $4, $4)", + ) + .bind(user_uid) + .bind(&secret) + .bind(&hashed_backup_codes) + .bind(now) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + } + + Ok(Enable2FAResponse { + secret, + qr_code, + backup_codes, + }) + } + + pub async fn auth_2fa_verify_and_enable( + &self, + context: &Session, + params: Verify2FAParams, + ) -> Result<(), AppError> { + let user_uid = context.user().ok_or(AppError::Unauthorized)?; + let two_fa = self + .find_2fa(user_uid) + .await? + .ok_or(AppError::TwoFactorNotSetup)?; + if two_fa.enabled { + return Err(AppError::TwoFactorAlreadyEnabled); + } + let secret = two_fa.secret.as_ref().ok_or(AppError::TwoFactorNotSetup)?; + if !self.verify_totp_code(secret, ¶ms.code)? { + return Err(AppError::InvalidTwoFactorCode); + } + + sqlx::query("UPDATE user_2fa SET enabled = true, updated_at = $1 WHERE user_id = $2") + .bind(chrono::Utc::now()) + .bind(user_uid) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + Ok(()) + } + + pub async fn auth_2fa_disable( + &self, + context: &Session, + params: Disable2FAParams, + ) -> Result<(), AppError> { + let user_uid = context.user().ok_or(AppError::Unauthorized)?; + let password = self.auth_rsa_decode(context, params.password).await?; + self.verify_user_password(user_uid, &password).await?; + + let two_fa = self + .find_2fa(user_uid) + .await? + .ok_or(AppError::TwoFactorNotSetup)?; + if !two_fa.enabled { + return Err(AppError::TwoFactorNotEnabled); + } + if !self + .verify_2fa_or_backup_code(&two_fa, ¶ms.code) + .await? + { + return Err(AppError::InvalidTwoFactorCode); + } + + sqlx::query("DELETE FROM user_2fa WHERE user_id = $1") + .bind(user_uid) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + Ok(()) + } + + pub async fn auth_2fa_verify(&self, user_uid: Uuid, code: &str) -> Result { + let Some(two_fa) = self.find_2fa(user_uid).await? else { + return Ok(true); + }; + if !two_fa.enabled { + return Ok(true); + } + self.verify_2fa_or_backup_code(&two_fa, code).await + } + + pub async fn auth_2fa_status_by_uid( + &self, + user_uid: Uuid, + ) -> Result { + let Some(two_fa) = self.find_2fa(user_uid).await? else { + return Ok(Get2FAStatusResponse { + is_enabled: false, + method: None, + has_backup_codes: false, + }); + }; + Ok(Get2FAStatusResponse { + is_enabled: two_fa.enabled, + method: Some("totp".into()), + has_backup_codes: !two_fa.backup_codes.is_empty(), + }) + } + + pub async fn auth_2fa_status( + &self, + context: &Session, + ) -> Result { + let user_uid = context.user().ok_or(AppError::Unauthorized)?; + self.auth_2fa_status_by_uid(user_uid).await + } + + pub async fn auth_2fa_verify_login( + &self, + context: &Session, + expected_user_uid: Uuid, + code: &str, + ) -> Result { + let Some(totp_key) = context.get::(Self::TOTP_KEY).ok().flatten() else { + return Ok(false); + }; + let Some(user_uid) = self.ctx.cache.get::(&totp_key) else { + context.remove(Self::TOTP_KEY); + return Ok(false); + }; + if user_uid != expected_user_uid { + context.remove(Self::TOTP_KEY); + let _ = self.ctx.cache.delete(&totp_key); + tracing::warn!(expected_user_uid = %expected_user_uid, pending_user_uid = %user_uid, "2FA pending user mismatch"); + return Ok(false); + } + + let verified = self.auth_2fa_verify(user_uid, code).await?; + if verified { + context.remove(Self::TOTP_KEY); + let _ = self.ctx.cache.delete(&totp_key); + } + Ok(verified) + } + + pub async fn auth_2fa_regenerate_backup_codes( + &self, + context: &Session, + password: String, + ) -> Result, AppError> { + let user_uid = context.user().ok_or(AppError::Unauthorized)?; + let password = self.auth_rsa_decode(context, password).await?; + self.verify_user_password(user_uid, &password).await?; + let two_fa = self + .find_2fa(user_uid) + .await? + .ok_or(AppError::TwoFactorNotSetup)?; + if !two_fa.enabled { + return Err(AppError::TwoFactorNotEnabled); + } + + let backup_codes = Self::generate_backup_codes(10); + sqlx::query("UPDATE user_2fa SET backup_codes = $1, updated_at = $2 WHERE user_id = $3") + .bind(self.hash_backup_codes(&backup_codes)?.join(".")) + .bind(chrono::Utc::now()) + .bind(user_uid) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + Ok(backup_codes) + } + + fn generate_totp_secret() -> String { + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let mut rng = rand::thread_rng(); + (0..32) + .map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() + } + + fn generate_backup_codes(count: usize) -> Vec { + let mut rng = rand::thread_rng(); + (0..count) + .map(|_| { + format!( + "{:04}-{:04}-{:04}", + rng.gen_range(0..10000), + rng.gen_range(0..10000), + rng.gen_range(0..10000) + ) + }) + .collect() + } + + fn backup_code_pepper(&self) -> Result { + self.ctx + .config + .env + .get("APP_SESSION_SECRET") + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| AppError::Config("APP_SESSION_SECRET is required".into())) + } + + fn hash_backup_code(&self, code: &str) -> Result { + let pepper = self.backup_code_pepper()?; + let mut mac = Hmac::::new_from_slice(pepper.as_bytes()) + .map_err(|_| AppError::InternalServerError("invalid backup code pepper".into()))?; + mac.update(code.trim().as_bytes()); + Ok(mac + .finalize() + .into_bytes() + .iter() + .map(|b| format!("{:02x}", b)) + .collect::()) + } + + fn hash_backup_codes(&self, codes: &[String]) -> Result, AppError> { + codes.iter().map(|c| self.hash_backup_code(c)).collect() + } + + fn verify_totp_code(&self, secret: &str, code: &str) -> Result { + let now = chrono::Utc::now().timestamp() as u64; + let time_step = 30; + let counter = now / time_step; + + for offset in [-1i64, 0, 1] { + let test_counter = (counter as i64 + offset) as u64; + let expected_code = self.generate_totp_code(secret, test_counter)?; + if constant_time_eq(&expected_code, code) { + return Ok(true); + } + } + Ok(false) + } + + fn generate_totp_code(&self, secret: &str, counter: u64) -> Result { + let secret_bytes = Self::decode_base32(secret)?; + let counter_bytes = counter.to_be_bytes(); + + let mut mac = Hmac::::new_from_slice(&secret_bytes) + .map_err(|_| AppError::InvalidTwoFactorCode)?; + mac.update(&counter_bytes); + let result = mac.finalize().into_bytes(); + + let offset = (result[19] & 0x0f) as usize; + let code = u32::from_be_bytes([ + result[offset] & 0x7f, + result[offset + 1], + result[offset + 2], + result[offset + 3], + ]); + + Ok(format!("{:06}", code % 1_000_000)) + } + + fn decode_base32(input: &str) -> Result, AppError> { + const CHARSET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let input = input.to_uppercase().replace("=", ""); + let mut bits = 0u64; + let mut bit_count = 0; + let mut output = Vec::new(); + + for c in input.chars() { + let val = CHARSET.find(c).ok_or(AppError::InvalidTwoFactorCode)? as u64; + bits = (bits << 5) | val; + bit_count += 5; + + if bit_count >= 8 { + bit_count -= 8; + output.push((bits >> bit_count) as u8); + bits &= (1 << bit_count) - 1; + } + } + Ok(output) + } + + async fn verify_user_password(&self, user_uid: Uuid, password: &str) -> Result<(), AppError> { + let row = sqlx::query("SELECT password_hash FROM user_password WHERE user_id = $1") + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::UserNotFound)?; + let hash: String = row.try_get("password_hash").map_err(AppError::Database)?; + + let password_hash = PasswordHash::new(&hash).map_err(|_| AppError::InvalidPassword)?; + Argon2::default() + .verify_password(password.as_bytes(), &password_hash) + .map_err(|_| AppError::InvalidPassword)?; + Ok(()) + } + + async fn find_2fa(&self, user_uid: Uuid) -> Result, AppError> { + sqlx::query_as::<_, User2Fa>( + "SELECT user_id, secret, backup_codes, enabled, created_at, updated_at \ + FROM user_2fa WHERE user_id = $1", + ) + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + async fn verify_2fa_or_backup_code( + &self, + two_fa: &User2Fa, + code: &str, + ) -> Result { + let secret = two_fa.secret.as_ref().ok_or(AppError::TwoFactorNotSetup)?; + if self.verify_totp_code(secret, code)? { + return Ok(true); + } + + let hashed_code = self.hash_backup_code(code)?; + let mut backup_codes: Vec = two_fa + .backup_codes + .split('.') + .filter(|c| !c.is_empty()) + .map(ToOwned::to_owned) + .collect(); + if backup_codes + .iter() + .any(|stored| constant_time_eq(stored, &hashed_code)) + { + backup_codes.retain(|stored| stored != &hashed_code); + sqlx::query( + "UPDATE user_2fa SET backup_codes = $1, updated_at = $2 WHERE user_id = $3", + ) + .bind(backup_codes.join(".")) + .bind(chrono::Utc::now()) + .bind(two_fa.user_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + return Ok(true); + } + Ok(false) + } +} + +use crate::service::util::constant_time_eq; diff --git a/service/context.rs b/service/context.rs new file mode 100644 index 0000000..18ff91d --- /dev/null +++ b/service/context.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use uuid::Uuid; + +use crate::cache::AppCache; +use crate::cache::redis::AppRedis; +use crate::config::AppConfig; +use crate::error::AppError; +use crate::etcd::EtcdRegistry; +use crate::models::db::AppDatabase; +use crate::queue::NatsQueue; +use crate::service::im::events::ImEventBus; +use crate::storage::s3::AppS3Storage; + +/// Shared infrastructure context for all domain services. +/// +/// Each sub-service (Auth, User, Workspace, Repo) holds an `Arc` +/// so they share the same database, cache, and other infrastructure without +/// duplicating ownership. +#[derive(Clone)] +pub struct ServiceContext { + pub version: String, + pub db: AppDatabase, + pub redis: AppRedis, + pub cache: Arc, + pub config: AppConfig, + pub storage: AppS3Storage, + /// etcd-based service registry for discovering git and mail RPC services. + pub registry: Arc, + /// NATS JetStream queue for real-time event broadcasting. + pub nats: Arc, + pub im_events: Arc, +} + +impl ServiceContext { + /// Run a block of work inside a database transaction. + /// + /// - Begins a transaction on the writer pool + /// - Sets `app.current_user_id` for RLS / audit triggers + /// - Commits on success, rolls back on error + pub async fn run_in_transaction(&self, user_uid: Uuid, f: F) -> Result + where + F: FnOnce(&mut sqlx::Transaction<'_, sqlx::Postgres>) -> Fut, + Fut: std::future::Future>, + { + let mut txn = self + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = f(&mut txn).await?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + + Ok(result) + } +} diff --git a/service/im/articles.rs b/service/im/articles.rs new file mode 100644 index 0000000..a7c3bee --- /dev/null +++ b/service/im/articles.rs @@ -0,0 +1,715 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::immediate::{ArticleAction, ArticleEvent}; +use crate::models::channels::{Article, ArticleComment, ArticleReaction}; +use crate::models::common::{ArticleStatus, Visibility}; +use crate::service::ImService; +use crate::service::im::events::ImEvent; + +use super::session::ImSession; +use super::util::*; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateArticleParams { + pub title: String, + pub summary: Option, + pub body: String, + pub cover_image_url: Option, + pub tags: Option>, + pub visibility: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateArticleParams { + pub title: Option, + pub summary: Option, + pub body: Option, + pub cover_image_url: Option, + pub tags: Option>, + pub visibility: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct ArticleListFilters { + pub status: Option, + pub tag: Option, + pub author_id: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateArticleCommentParams { + pub body: String, + pub parent_comment_id: Option, +} + +impl ImService { + async fn article_realtime(&self, channel_id: Uuid, article_id: Uuid, action: ArticleAction) { + let request_id = Uuid::nil(); + let event = ArticleEvent { + channel_id, + article_id, + action, + }; + self.publish(&format!("im.article.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Article { + request_id, + data: event, + }); + } + + pub async fn article_list( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + filters: ArticleListFilters, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + let status = filters + .status + .as_deref() + .and_then(|s| s.parse::().ok()) + .filter(|s| *s != ArticleStatus::Unknown); + + sqlx::query_as::<_, Article>( + "SELECT id, channel_id, author_id, title, slug, summary, body, cover_image_url, \ + status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \ + views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \ + metadata, created_at, updated_at, deleted_at \ + FROM article WHERE channel_id = $1 AND deleted_at IS NULL \ + AND ($2::text IS NULL OR status::text = $2) \ + AND ($3::uuid IS NULL OR author_id = $3) \ + AND ($4::text IS NULL OR $4 = ANY(tags)) \ + ORDER BY created_at DESC LIMIT $5 OFFSET $6", + ) + .bind(channel_id) + .bind(status.map(|s| s.to_string())) + .bind(filters.author_id) + .bind(filters.tag.as_deref()) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn article_get( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + article_id: Uuid, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + let article = self.resolve_article(article_id, channel_id).await?; + + // Increment view count (best-effort, not in a txn) + let _ = sqlx::query("UPDATE article SET views_count = views_count + 1 WHERE id = $1") + .bind(article_id) + .execute(self.ctx.db.writer()) + .await; + + Ok(article) + } + + pub async fn article_create( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + params: CreateArticleParams, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_editable(user_uid, &channel).await?; + + let title = required_text(params.title, "title")?; + if title.len() > MAX_ARTICLE_TITLE { + return Err(AppError::BadRequest("article title too long".into())); + } + let body = required_text(params.body, "body")?; + + let visibility = parse_enum( + params.visibility, + Visibility::Public, + Visibility::Unknown, + "visibility", + )?; + + let slug = self.generate_article_slug(channel_id, &title).await?; + let now = chrono::Utc::now(); + let tags = params.tags.unwrap_or_default(); + + let article = sqlx::query_as::<_, Article>( + "INSERT INTO article \ + (id, channel_id, author_id, title, slug, summary, body, cover_image_url, \ + status, visibility, tags, cross_posted, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'draft', $9, $10, false, $11, $11) \ + RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \ + status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \ + views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \ + metadata, created_at, updated_at, deleted_at", + ) + .bind(Uuid::now_v7()) + .bind(channel_id) + .bind(user_uid) + .bind(&title) + .bind(&slug) + .bind(params.summary.as_deref()) + .bind(&body) + .bind(params.cover_image_url.as_deref()) + .bind(visibility) + .bind(&tags) + .bind(now) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + self.article_realtime(channel_id, article.id, ArticleAction::Created) + .await; + Ok(article) + } + + pub async fn article_update( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + article_id: Uuid, + params: UpdateArticleParams, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + let article = self.resolve_article(article_id, channel_id).await?; + + if article.author_id != user_uid { + self.ensure_channel_admin(user_uid, &channel).await?; + } + + let new_title = match params.title { + Some(t) => { + let t = required_text(t, "title")?; + if t.len() > MAX_ARTICLE_TITLE { + return Err(AppError::BadRequest("article title too long".into())); + } + t + } + None => article.title, + }; + let new_body = params.body.unwrap_or(article.body); + let new_summary = params.summary.or(article.summary); + let new_cover = params.cover_image_url.or(article.cover_image_url); + let new_tags = params.tags.unwrap_or(article.tags); + let visibility = parse_enum( + params.visibility, + article.visibility, + Visibility::Unknown, + "visibility", + )?; + + let now = chrono::Utc::now(); + let updated = sqlx::query_as::<_, Article>( + "UPDATE article SET title = $1, summary = $2, body = $3, cover_image_url = $4, \ + tags = $5, visibility = $6, updated_at = $7 \ + WHERE id = $8 AND deleted_at IS NULL \ + RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \ + status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \ + views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \ + metadata, created_at, updated_at, deleted_at", + ) + .bind(&new_title) + .bind(&new_summary) + .bind(&new_body) + .bind(&new_cover) + .bind(&new_tags) + .bind(visibility) + .bind(now) + .bind(article_id) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + self.article_realtime(channel_id, article_id, ArticleAction::Updated) + .await; + Ok(updated) + } + + pub async fn article_publish( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + article_id: Uuid, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + let article = self.resolve_article(article_id, channel_id).await?; + + if article.author_id != user_uid { + self.ensure_channel_editable(user_uid, &channel).await?; + } + + if article.status != ArticleStatus::Draft && article.status != ArticleStatus::Scheduled { + return Err(AppError::BadRequest( + "only draft or scheduled articles can be published".into(), + )); + } + + let now = chrono::Utc::now(); + let published = sqlx::query_as::<_, Article>( + "UPDATE article SET status = 'published', published_at = $1, published_by = $2, \ + updated_at = $1 \ + WHERE id = $3 AND deleted_at IS NULL \ + RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \ + status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \ + views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \ + metadata, created_at, updated_at, deleted_at", + ) + .bind(now) + .bind(user_uid) + .bind(article_id) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + // Trigger cross-posts to followers + if let Err(e) = self + .cross_post_article(article_id, channel_id, user_uid) + .await + { + tracing::warn!(article_id = %article_id, error = %e, "cross-post failed"); + } + + tracing::info!(article_id = %article_id, "Article published"); + self.article_realtime(channel_id, article_id, ArticleAction::Published) + .await; + Ok(published) + } + + pub async fn article_unpublish( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + article_id: Uuid, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + let article = self.resolve_article(article_id, channel_id).await?; + + if article.author_id != user_uid { + self.ensure_channel_editable(user_uid, &channel).await?; + } + + let now = chrono::Utc::now(); + let unpublished = sqlx::query_as::<_, Article>( + "UPDATE article SET status = 'unpublished', unpublished_at = $1, updated_at = $1 \ + WHERE id = $2 AND status = 'published' AND deleted_at IS NULL \ + RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \ + status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \ + views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \ + metadata, created_at, updated_at, deleted_at", + ) + .bind(now) + .bind(article_id) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + self.article_realtime(channel_id, article_id, ArticleAction::Unpublished) + .await; + Ok(unpublished) + } + + pub async fn article_schedule( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + article_id: Uuid, + scheduled_at: chrono::DateTime, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + let article = self.resolve_article(article_id, channel_id).await?; + + if article.author_id != user_uid { + self.ensure_channel_editable(user_uid, &channel).await?; + } + + if article.status != ArticleStatus::Draft { + return Err(AppError::BadRequest( + "only draft articles can be scheduled".into(), + )); + } + + let now = chrono::Utc::now(); + let scheduled = sqlx::query_as::<_, Article>( + "UPDATE article SET status = 'scheduled', scheduled_at = $1, updated_at = $2 \ + WHERE id = $3 AND deleted_at IS NULL \ + RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \ + status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \ + views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \ + metadata, created_at, updated_at, deleted_at", + ) + .bind(scheduled_at) + .bind(now) + .bind(article_id) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + self.article_realtime(channel_id, article_id, ArticleAction::Updated) + .await; + Ok(scheduled) + } + + pub async fn article_delete( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + article_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + let article = self.resolve_article(article_id, channel_id).await?; + + if article.author_id != user_uid { + self.ensure_channel_admin(user_uid, &channel).await?; + } + + let now = chrono::Utc::now(); + let result = sqlx::query( + "UPDATE article SET deleted_at = $1, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL", + ) + .bind(now) + .bind(article_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + ensure_affected(result.rows_affected(), "article not found")?; + self.article_realtime(channel_id, article_id, ArticleAction::Deleted) + .await; + Ok(()) + } + + pub async fn article_comment_list( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + article_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + sqlx::query_as::<_, ArticleComment>( + "SELECT id, article_id, channel_id, author_id, parent_comment_id, body, \ + edited_at, deleted_at, created_at, updated_at \ + FROM article_comment WHERE article_id = $1 AND deleted_at IS NULL \ + ORDER BY created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(article_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn article_comment_create( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + article_id: Uuid, + params: CreateArticleCommentParams, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + let body = required_text(params.body, "body")?; + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let comment = sqlx::query_as::<_, ArticleComment>( + "INSERT INTO article_comment \ + (id, article_id, channel_id, author_id, parent_comment_id, body, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $7) \ + RETURNING id, article_id, channel_id, author_id, parent_comment_id, body, \ + edited_at, deleted_at, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(article_id) + .bind(channel_id) + .bind(user_uid) + .bind(params.parent_comment_id) + .bind(&body) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query("UPDATE article SET comments_count = comments_count + 1 WHERE id = $1") + .bind(article_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + self.article_realtime(channel_id, article_id, ArticleAction::Updated) + .await; + Ok(comment) + } + + pub async fn article_comment_delete( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + article_id: Uuid, + comment_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + + let comment = sqlx::query_as::<_, ArticleComment>( + "SELECT id, article_id, channel_id, author_id, parent_comment_id, body, \ + edited_at, deleted_at, created_at, updated_at \ + FROM article_comment WHERE id = $1 AND article_id = $2 AND deleted_at IS NULL", + ) + .bind(comment_id) + .bind(article_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("comment not found".into()))?; + + if comment.author_id != user_uid { + self.ensure_channel_admin(user_uid, &channel).await?; + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query("UPDATE article_comment SET deleted_at = $1, updated_at = $1 WHERE id = $2") + .bind(now) + .bind(comment_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE article SET comments_count = GREATEST(comments_count - 1, 0) WHERE id = $1", + ) + .bind(article_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + self.article_realtime(channel_id, article_id, ArticleAction::Updated) + .await; + Ok(()) + } + + pub async fn article_reaction_add( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + article_id: Uuid, + content: &str, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + let content = required_text(content.to_string(), "content")?; + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let reaction = sqlx::query_as::<_, ArticleReaction>( + "INSERT INTO article_reaction (id, article_id, channel_id, user_id, content, created_at) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + ON CONFLICT (article_id, user_id, content) DO NOTHING \ + RETURNING id, article_id, channel_id, user_id, content, created_at", + ) + .bind(Uuid::now_v7()) + .bind(article_id) + .bind(channel_id) + .bind(user_uid) + .bind(&content) + .bind(now) + .fetch_optional(&mut *txn) + .await + .map_err(AppError::Database)?; + + if reaction.is_some() { + sqlx::query("UPDATE article SET reactions_count = reactions_count + 1 WHERE id = $1") + .bind(article_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } + + txn.commit().await.map_err(|_| AppError::TxnError)?; + let reaction = reaction.ok_or(AppError::Conflict("reaction already exists".into()))?; + self.article_realtime(channel_id, article_id, ArticleAction::Updated) + .await; + Ok(reaction) + } + + pub async fn article_reaction_remove( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + article_id: Uuid, + content: &str, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + let _now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "DELETE FROM article_reaction WHERE article_id = $1 AND user_id = $2 AND content = $3", + ) + .bind(article_id) + .bind(user_uid) + .bind(content) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "reaction not found")?; + + sqlx::query( + "UPDATE article SET reactions_count = GREATEST(reactions_count - 1, 0) WHERE id = $1", + ) + .bind(article_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + self.article_realtime(channel_id, article_id, ArticleAction::Updated) + .await; + Ok(()) + } + + pub(crate) async fn resolve_article( + &self, + article_id: Uuid, + channel_id: Uuid, + ) -> Result { + sqlx::query_as::<_, Article>( + "SELECT id, channel_id, author_id, title, slug, summary, body, cover_image_url, \ + status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \ + views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \ + metadata, created_at, updated_at, deleted_at \ + FROM article WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL", + ) + .bind(article_id) + .bind(channel_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("article not found".into())) + } + + async fn generate_article_slug( + &self, + channel_id: Uuid, + title: &str, + ) -> Result { + let base = slugify(title); + let mut slug = base.clone(); + let mut counter = 1u32; + + loop { + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM article WHERE channel_id = $1 AND slug = $2)", + ) + .bind(channel_id) + .bind(&slug) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if !exists { + return Ok(slug); + } + slug = format!("{base}-{counter}"); + counter += 1; + if counter > 100 { + return Err(AppError::InternalServerError( + "failed to generate unique slug".into(), + )); + } + } + } +} diff --git a/service/im/categories.rs b/service/im/categories.rs new file mode 100644 index 0000000..ab402cc --- /dev/null +++ b/service/im/categories.rs @@ -0,0 +1,176 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::immediate::{CategoryAction, CategoryEvent}; +use crate::models::channels::ChannelCategory; +use crate::models::common::Role; +use crate::service::ImService; +use crate::service::im::events::ImEvent; + +use super::session::ImSession; +use super::util::*; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateCategoryParams { + pub name: String, + pub position: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateCategoryParams { + pub name: Option, + pub position: Option, + pub collapsed: Option, +} + +impl ImService { + async fn category_realtime( + &self, + workspace_name: &str, + category_id: Uuid, + action: CategoryAction, + ) { + let request_id = Uuid::nil(); + let event = CategoryEvent { + workspace_name: workspace_name.to_string(), + category_id, + action, + }; + self.publish(&format!("im.category.{workspace_name}"), request_id, &event) + .await; + self.emit_event(ImEvent::Category { + request_id, + data: event, + }); + } + + pub async fn category_list( + &self, + ctx: &ImSession, + wk_name: &str, + ) -> Result, AppError> { + let user_uid = ctx.user; + let ws = self.resolve_workspace(wk_name).await?; + self.ensure_workspace_readable(user_uid, &ws).await?; + + sqlx::query_as::<_, ChannelCategory>( + "SELECT id, workspace_id, name, position, collapsed, created_by, created_at, updated_at \ + FROM channel_category WHERE workspace_id = $1 ORDER BY position ASC, name ASC", + ) + .bind(ws.id) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn category_create( + &self, + ctx: &ImSession, + wk_name: &str, + params: CreateCategoryParams, + ) -> Result { + let user_uid = ctx.user; + let ws = self.resolve_workspace(wk_name).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Member) + .await?; + + let name = required_text(params.name, "name")?; + let now = chrono::Utc::now(); + + let category = sqlx::query_as::<_, ChannelCategory>( + "INSERT INTO channel_category (id, workspace_id, name, position, collapsed, created_by, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, false, $5, $6, $6) \ + RETURNING id, workspace_id, name, position, collapsed, created_by, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(ws.id) + .bind(&name) + .bind(params.position.unwrap_or(0)) + .bind(user_uid) + .bind(now) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + self.category_realtime(wk_name, category.id, CategoryAction::Created) + .await; + Ok(category) + } + + pub async fn category_update( + &self, + ctx: &ImSession, + wk_name: &str, + category_id: Uuid, + params: UpdateCategoryParams, + ) -> Result { + let user_uid = ctx.user; + let ws = self.resolve_workspace(wk_name).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Member) + .await?; + + let cat = self.resolve_category(category_id, ws.id).await?; + let new_name = params.name.unwrap_or(cat.name); + let now = chrono::Utc::now(); + + let category = sqlx::query_as::<_, ChannelCategory>( + "UPDATE channel_category SET name = $1, position = COALESCE($2, position), \ + collapsed = COALESCE($3, collapsed), updated_at = $4 \ + WHERE id = $5 \ + RETURNING id, workspace_id, name, position, collapsed, created_by, created_at, updated_at", + ) + .bind(&new_name) + .bind(params.position) + .bind(params.collapsed) + .bind(now) + .bind(category_id) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + self.category_realtime(wk_name, category_id, CategoryAction::Updated) + .await; + Ok(category) + } + + pub async fn category_delete( + &self, + ctx: &ImSession, + wk_name: &str, + category_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let ws = self.resolve_workspace(wk_name).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + + let result = + sqlx::query("DELETE FROM channel_category WHERE id = $1 AND workspace_id = $2") + .bind(category_id) + .bind(ws.id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + ensure_affected(result.rows_affected(), "category not found")?; + self.category_realtime(wk_name, category_id, CategoryAction::Deleted) + .await; + Ok(()) + } + + pub(crate) async fn resolve_category( + &self, + category_id: Uuid, + workspace_id: Uuid, + ) -> Result { + sqlx::query_as::<_, ChannelCategory>( + "SELECT id, workspace_id, name, position, collapsed, created_by, created_at, updated_at \ + FROM channel_category WHERE id = $1 AND workspace_id = $2", + ) + .bind(category_id) + .bind(workspace_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("category not found".into())) + } +} diff --git a/service/im/channels.rs b/service/im/channels.rs new file mode 100644 index 0000000..6878ae9 --- /dev/null +++ b/service/im/channels.rs @@ -0,0 +1,554 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::channels::Channel; +use crate::models::common::{ChannelKind, ChannelType, Role, Visibility}; +use crate::models::workspaces::Workspace; +use crate::service::ImService; + +use super::session::ImSession; +use super::util::*; +use crate::immediate::{ChannelAction, ChannelEvent}; +use crate::service::im::events::ImEvent; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateChannelParams { + pub name: String, + pub topic: Option, + pub description: Option, + pub channel_type: Option, + pub channel_kind: Option, + pub visibility: Option, + pub category_id: Option, + pub parent_channel_id: Option, + pub nsfw: Option, + pub rate_limit_per_user: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateChannelParams { + pub name: Option, + pub topic: Option, + pub description: Option, + pub visibility: Option, + pub category_id: Option, + pub position: Option, + pub nsfw: Option, + pub rate_limit_per_user: Option, + pub archived: Option, + pub read_only: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct ChannelListFilters { + pub channel_type: Option, + pub channel_kind: Option, + pub category_id: Option, + pub archived: Option, +} + +impl ImService { + pub async fn channel_list( + &self, + ctx: &ImSession, + wk_name: &str, + filters: ChannelListFilters, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user; + let ws = self.resolve_workspace(wk_name).await?; + self.ensure_workspace_readable(user_uid, &ws).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + let kind = filters + .channel_kind + .as_deref() + .and_then(|s| s.parse::().ok()) + .filter(|k| *k != ChannelKind::Unknown); + let ch_type = filters + .channel_type + .as_deref() + .and_then(|s| s.parse::().ok()) + .filter(|t| *t != ChannelType::Unknown); + + sqlx::query_as::<_, Channel>( + "SELECT id, workspace_id, repo_id, category_id, created_by, name, topic, description, \ + channel_type, channel_kind, visibility, position, nsfw, archived, read_only, \ + bitrate, user_limit, rtc_region, \ + default_auto_archive_duration, default_reaction_emoji, default_sort_order, \ + default_forum_layout, require_tag, available_tags, default_thread_rate_limit, \ + rate_limit_per_user, parent_channel_id, \ + last_message_id, last_message_at, archived_at, created_at, updated_at, deleted_at \ + FROM channel \ + WHERE workspace_id = $1 AND deleted_at IS NULL \ + AND ($2::text IS NULL OR channel_kind::text = $2) \ + AND ($3::text IS NULL OR channel_type::text = $3) \ + AND ($4::uuid IS NULL OR category_id = $4) \ + AND ($5::bool IS NULL OR archived = $5) \ + ORDER BY position ASC NULLS LAST, name ASC \ + LIMIT $6 OFFSET $7", + ) + .bind(ws.id) + .bind(kind.map(|k| k.to_string())) + .bind(ch_type.map(|t| t.to_string())) + .bind(filters.category_id) + .bind(filters.archived) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn channel_get( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + Ok(channel) + } + + #[tracing::instrument(skip(self, ctx, params), fields(name = %params.name))] + pub async fn channel_create( + &self, + ctx: &ImSession, + wk_name: &str, + params: CreateChannelParams, + request_id: Uuid, + ) -> Result { + let user_uid = ctx.user; + let name = required_text(params.name, "name")?; + if name.len() > MAX_CHANNEL_NAME { + return Err(AppError::BadRequest("channel name too long".into())); + } + let ws = self.resolve_workspace(wk_name).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Member) + .await?; + + if let Some(topic) = ¶ms.topic + && topic.len() > MAX_CHANNEL_TOPIC + { + return Err(AppError::BadRequest("channel topic too long".into())); + } + + let ch_kind = parse_enum( + params.channel_kind, + ChannelKind::Text, + ChannelKind::Unknown, + "channel_kind", + )?; + let ch_type = parse_enum( + params.channel_type, + ChannelType::Public, + ChannelType::Unknown, + "channel_type", + )?; + let visibility = parse_enum( + params.visibility, + Visibility::Public, + Visibility::Unknown, + "visibility", + )?; + + let now = chrono::Utc::now(); + let channel_id = Uuid::now_v7(); + + let channel = sqlx::query_as::<_, Channel>( + "INSERT INTO channel \ + (id, workspace_id, repo_id, category_id, created_by, name, topic, description, \ + channel_type, channel_kind, visibility, position, nsfw, archived, read_only, \ + rate_limit_per_user, parent_channel_id, created_at, updated_at) \ + VALUES ($1, $2, NULL, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, false, false, \ + $13, $14, $15, $15) \ + RETURNING id, workspace_id, repo_id, category_id, created_by, name, topic, description, \ + channel_type, channel_kind, visibility, position, nsfw, archived, read_only, \ + bitrate, user_limit, rtc_region, \ + default_auto_archive_duration, default_reaction_emoji, default_sort_order, \ + default_forum_layout, require_tag, available_tags, default_thread_rate_limit, \ + rate_limit_per_user, parent_channel_id, \ + last_message_id, last_message_at, archived_at, created_at, updated_at, deleted_at", + ) + .bind(channel_id) + .bind(ws.id) + .bind(params.category_id) + .bind(user_uid) + .bind(&name) + .bind(params.topic.as_deref()) + .bind(params.description.as_deref()) + .bind(ch_type) + .bind(ch_kind) + .bind(visibility) + .bind(0_i32) // position + .bind(params.nsfw.unwrap_or(false)) + .bind(params.rate_limit_per_user) + .bind(params.parent_channel_id) + .bind(now) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + // Auto-add creator as channel member with owner role + sqlx::query( + "INSERT INTO channel_member \ + (id, channel_id, user_id, role, status, muted, pinned, created_at, updated_at) \ + VALUES ($1, $2, $3, 'owner', 'active', false, false, $4, $4)", + ) + .bind(Uuid::now_v7()) + .bind(channel_id) + .bind(user_uid) + .bind(now) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + tracing::info!(channel_id = %channel_id, name = %name, "Channel created"); + + let event = ChannelEvent { + channel_id: channel.id, + action: ChannelAction::Created, + workspace_name: Some(ws.name.clone()), + }; + self.publish(&format!("im.channel.{}", ws.name), request_id, &event) + .await; + self.emit_event(ImEvent::Channel { + request_id, + data: event, + }); + + Ok(channel) + } + + pub async fn channel_update( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + params: UpdateChannelParams, + request_id: Uuid, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_editable(user_uid, &channel).await?; + + if let Some(name) = ¶ms.name + && name.len() > MAX_CHANNEL_NAME + { + return Err(AppError::BadRequest("channel name too long".into())); + } + + let visibility = match params.visibility { + Some(ref v) => parse_enum( + Some(v.clone()), + channel.visibility, + Visibility::Unknown, + "visibility", + )?, + None => channel.visibility, + }; + + let now = chrono::Utc::now(); + let new_name = merge_optional_text(params.name, Some(channel.name.clone())) + .map(|s| s.trim().to_string()) + .unwrap_or(channel.name); + let new_topic = merge_optional_text(params.topic, channel.topic.clone()); + let new_desc = merge_optional_text(params.description, channel.description.clone()); + + let updated = sqlx::query_as::<_, Channel>( + "UPDATE channel SET \ + name = $1, topic = $2, description = $3, visibility = $4, \ + category_id = COALESCE($5, category_id), position = COALESCE($6, position), \ + nsfw = COALESCE($7, nsfw), archived = COALESCE($8, archived), \ + read_only = COALESCE($9, read_only), \ + rate_limit_per_user = COALESCE($10, rate_limit_per_user), \ + updated_at = $11 \ + WHERE id = $12 AND deleted_at IS NULL \ + RETURNING id, workspace_id, repo_id, category_id, created_by, name, topic, description, \ + channel_type, channel_kind, visibility, position, nsfw, archived, read_only, \ + bitrate, user_limit, rtc_region, \ + default_auto_archive_duration, default_reaction_emoji, default_sort_order, \ + default_forum_layout, require_tag, available_tags, default_thread_rate_limit, \ + rate_limit_per_user, parent_channel_id, \ + last_message_id, last_message_at, archived_at, created_at, updated_at, deleted_at", + ) + .bind(&new_name) + .bind(&new_topic) + .bind(&new_desc) + .bind(visibility) + .bind(params.category_id) + .bind(params.position) + .bind(params.nsfw) + .bind(params.archived) + .bind(params.read_only) + .bind(params.rate_limit_per_user) + .bind(now) + .bind(channel_id) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + let event = ChannelEvent { + channel_id, + action: ChannelAction::Updated, + workspace_name: None, + }; + self.publish(&format!("im.channel.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Channel { + request_id, + data: event, + }); + + Ok(updated) + } + + pub async fn channel_delete( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + request_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_admin(user_uid, &channel).await?; + + let now = chrono::Utc::now(); + let result = sqlx::query( + "UPDATE channel SET deleted_at = $1, updated_at = $1 \ + WHERE id = $2 AND deleted_at IS NULL", + ) + .bind(now) + .bind(channel_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + ensure_affected(result.rows_affected(), "channel not found")?; + + let event = ChannelEvent { + channel_id, + action: ChannelAction::Deleted, + workspace_name: None, + }; + self.publish(&format!("im.channel.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Channel { + request_id, + data: event, + }); + + Ok(()) + } + + pub(crate) async fn resolve_workspace(&self, wk_name: &str) -> Result { + Workspace::find_by_name(self.ctx.db.reader(), wk_name) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("workspace not found".into())) + } + + pub(crate) async fn resolve_channel(&self, channel_id: Uuid) -> Result { + sqlx::query_as::<_, Channel>( + "SELECT id, workspace_id, repo_id, category_id, created_by, name, topic, description, \ + channel_type, channel_kind, visibility, position, nsfw, archived, read_only, \ + bitrate, user_limit, rtc_region, \ + default_auto_archive_duration, default_reaction_emoji, default_sort_order, \ + default_forum_layout, require_tag, available_tags, default_thread_rate_limit, \ + rate_limit_per_user, parent_channel_id, \ + last_message_id, last_message_at, archived_at, created_at, updated_at, deleted_at \ + FROM channel WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(channel_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("channel not found".into())) + } + + pub(crate) async fn ensure_workspace_readable( + &self, + user_uid: Uuid, + ws: &Workspace, + ) -> Result<(), AppError> { + if Workspace::is_readable(self.ctx.db.reader(), ws, user_uid) + .await + .map_err(AppError::Database)? + { + Ok(()) + } else { + Err(AppError::Unauthorized) + } + } + + pub(crate) async fn ensure_workspace_role_at_least( + &self, + user_uid: Uuid, + ws: &Workspace, + min_role: Role, + ) -> Result { + let role = Workspace::user_role(self.ctx.db.reader(), ws.id, user_uid, ws.owner_id) + .await + .map_err(AppError::Database)? + .unwrap_or(Role::Unknown); + if role_level(role) < role_level(min_role) { + return Err(AppError::Unauthorized); + } + Ok(role) + } + + pub(crate) async fn ensure_channel_readable( + &self, + user_uid: Uuid, + channel: &Channel, + ) -> Result<(), AppError> { + if channel.created_by == user_uid { + return Ok(()); + } + let is_member = self.is_channel_member(channel.id, user_uid).await?; + if is_member { + return Ok(()); + } + if channel.visibility == Visibility::Public { + let ws = Workspace::find_by_id(self.ctx.db.reader(), channel.workspace_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("workspace not found".into()))?; + if Workspace::is_readable(self.ctx.db.reader(), &ws, user_uid) + .await + .map_err(AppError::Database)? + { + return Ok(()); + } + } + Err(AppError::Unauthorized) + } + + pub(crate) async fn ensure_channel_member( + &self, + user_uid: Uuid, + channel: &Channel, + ) -> Result<(), AppError> { + if channel.created_by == user_uid { + return Ok(()); + } + let is_member = self.is_channel_member(channel.id, user_uid).await?; + if is_member { + Ok(()) + } else { + Err(AppError::Forbidden("not a channel member".into())) + } + } + + pub(crate) async fn ensure_channel_editable( + &self, + user_uid: Uuid, + channel: &Channel, + ) -> Result<(), AppError> { + if channel.created_by == user_uid { + return Ok(()); + } + let role = self.channel_member_role(channel.id, user_uid).await?; + if role_level(role) >= role_level(Role::Member) { + return Ok(()); + } + self.ensure_workspace_role_at_least( + user_uid, + &Workspace::find_by_id(self.ctx.db.reader(), channel.workspace_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("workspace not found".into()))?, + Role::Admin, + ) + .await?; + Ok(()) + } + + pub(crate) async fn ensure_channel_admin( + &self, + user_uid: Uuid, + channel: &Channel, + ) -> Result<(), AppError> { + let role = self.channel_member_role(channel.id, user_uid).await?; + if role_level(role) >= role_level(Role::Admin) { + return Ok(()); + } + self.ensure_workspace_role_at_least( + user_uid, + &Workspace::find_by_id(self.ctx.db.reader(), channel.workspace_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("workspace not found".into()))?, + Role::Admin, + ) + .await?; + Ok(()) + } + + pub(crate) async fn is_channel_member( + &self, + channel_id: Uuid, + user_uid: Uuid, + ) -> Result { + sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM channel_member \ + WHERE channel_id = $1 AND user_id = $2 AND status = 'active')", + ) + .bind(channel_id) + .bind(user_uid) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub(crate) async fn channel_member_role( + &self, + channel_id: Uuid, + user_uid: Uuid, + ) -> Result { + let role: Option = sqlx::query_scalar( + "SELECT role::text FROM channel_member \ + WHERE channel_id = $1 AND user_id = $2 AND status = 'active'", + ) + .bind(channel_id) + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + Ok(role + .as_deref() + .and_then(|s| s.parse::().ok()) + .unwrap_or(Role::Unknown)) + } + + pub(crate) async fn update_channel_stats( + &self, + channel_id: Uuid, + now: chrono::DateTime, + txn: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), AppError> { + sqlx::query( + "UPDATE channel_stats SET \ + members_count = (SELECT COUNT(*) FROM channel_member WHERE channel_id = $1 AND status = 'active'), \ + messages_count = (SELECT COUNT(*) FROM message WHERE channel_id = $1 AND deleted_at IS NULL), \ + threads_count = (SELECT COUNT(*) FROM message_thread WHERE channel_id = $1), \ + reactions_count = (SELECT COUNT(*) FROM message_reaction WHERE channel_id = $1), \ + mentions_count = (SELECT COUNT(*) FROM message_mention WHERE channel_id = $1), \ + files_count = (SELECT COUNT(*) FROM message_attachment WHERE channel_id = $1), \ + last_activity_at = $2, updated_at = $2 \ + WHERE channel_id = $1", + ) + .bind(channel_id) + .bind(now) + .execute(&mut **txn) + .await + .map_err(AppError::Database)?; + Ok(()) + } +} diff --git a/service/im/delivery_trace.rs b/service/im/delivery_trace.rs new file mode 100644 index 0000000..4635dda --- /dev/null +++ b/service/im/delivery_trace.rs @@ -0,0 +1,45 @@ +use uuid::Uuid; + +pub fn trace_request(stage: &'static str, request_id: Uuid, subject: &str) { + tracing::info!( + target: "im.delivery", + stage, + request_id = %request_id, + subject, + "im delivery trace" + ); +} + +pub fn trace_message( + stage: &'static str, + request_id: Uuid, + channel_id: Uuid, + message_id: Uuid, + seq: Option, +) { + tracing::info!( + target: "im.delivery", + stage, + request_id = %request_id, + channel_id = %channel_id, + message_id = %message_id, + seq, + "im message delivery trace" + ); +} + +pub fn trace_error( + stage: &'static str, + request_id: Uuid, + subject: &str, + error: &dyn std::fmt::Display, +) { + tracing::warn!( + target: "im.delivery", + stage, + request_id = %request_id, + subject, + error = %error, + "im delivery trace failed" + ); +} diff --git a/service/im/drafts.rs b/service/im/drafts.rs new file mode 100644 index 0000000..3e8451a --- /dev/null +++ b/service/im/drafts.rs @@ -0,0 +1,162 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::immediate::{DraftAction, DraftEvent}; +use crate::models::channels::MessageDraft; +use crate::service::ImService; +use crate::service::im::events::ImEvent; + +use super::session::ImSession; +use super::util::*; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct SaveDraftParams { + pub content: String, + pub thread_id: Option, + pub reply_to_message_id: Option, +} + +impl ImService { + async fn draft_realtime( + &self, + channel_id: Uuid, + user_id: Uuid, + thread_id: Option, + action: DraftAction, + ) { + let request_id = Uuid::nil(); + let event = DraftEvent { + channel_id, + user_id, + thread_id, + action, + }; + self.publish(&format!("im.draft.{user_id}"), request_id, &event) + .await; + self.emit_event(ImEvent::Draft { + request_id, + data: event, + }); + } + + pub async fn draft_save( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + params: SaveDraftParams, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + if params.content.len() > MAX_MESSAGE_BODY { + return Err(AppError::BadRequest("draft content too long".into())); + } + + // NOTE: COALESCE(thread_id, nil_uuid) in ON CONFLICT requires a matching + // UNIQUE index with the identical COALESCE expression. + let now = chrono::Utc::now(); + let draft = sqlx::query_as::<_, MessageDraft>( + "INSERT INTO message_draft \ + (id, user_id, channel_id, thread_id, reply_to_message_id, content, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $7) \ + ON CONFLICT (user_id, channel_id, COALESCE(thread_id, '00000000-0000-0000-0000-000000000000'::uuid)) \ + DO UPDATE SET content = $6, reply_to_message_id = $5, updated_at = $7 \ + RETURNING id, user_id, channel_id, thread_id, reply_to_message_id, content, \ + attachments, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(user_uid) + .bind(channel_id) + .bind(params.thread_id) + .bind(params.reply_to_message_id) + .bind(¶ms.content) + .bind(now) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + self.draft_realtime(channel_id, user_uid, draft.thread_id, DraftAction::Saved) + .await; + Ok(draft) + } + + pub async fn draft_get( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + thread_id: Option, + ) -> Result, AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + sqlx::query_as::<_, MessageDraft>( + "SELECT id, user_id, channel_id, thread_id, reply_to_message_id, content, \ + attachments, created_at, updated_at \ + FROM message_draft \ + WHERE user_id = $1 AND channel_id = $2 \ + AND (thread_id = $3 OR (thread_id IS NULL AND $3 IS NULL))", + ) + .bind(user_uid) + .bind(channel_id) + .bind(thread_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn draft_delete( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + thread_id: Option, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + + let result = sqlx::query( + "DELETE FROM message_draft \ + WHERE user_id = $1 AND channel_id = $2 \ + AND (thread_id = $3 OR (thread_id IS NULL AND $3 IS NULL))", + ) + .bind(user_uid) + .bind(channel_id) + .bind(thread_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + ensure_affected(result.rows_affected(), "draft not found")?; + self.draft_realtime(channel_id, user_uid, thread_id, DraftAction::Deleted) + .await; + Ok(()) + } + + pub async fn draft_list( + &self, + ctx: &ImSession, + wk_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user; + let _ = self.resolve_workspace(wk_name).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + sqlx::query_as::<_, MessageDraft>( + "SELECT id, user_id, channel_id, thread_id, reply_to_message_id, content, \ + attachments, created_at, updated_at \ + FROM message_draft WHERE user_id = $1 \ + ORDER BY updated_at DESC LIMIT $2 OFFSET $3", + ) + .bind(user_uid) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } +} diff --git a/service/im/events.rs b/service/im/events.rs new file mode 100644 index 0000000..72f2e09 --- /dev/null +++ b/service/im/events.rs @@ -0,0 +1,100 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use tokio::sync::broadcast; +use uuid::Uuid; + +use crate::immediate::{ + ArticleEvent, CategoryEvent, ChannelEvent, DraftEvent, FollowEvent, MemberEvent, MessageEvent, + PollEvent, PresenceEvent, ReactionEvent, ThreadEvent, TypingEvent, +}; + +#[derive(Debug, Clone)] +pub enum ImEvent { + Typing { + request_id: Uuid, + data: TypingEvent, + }, + Presence { + request_id: Uuid, + data: PresenceEvent, + }, + Message { + request_id: Uuid, + data: MessageEvent, + }, + Channel { + request_id: Uuid, + data: ChannelEvent, + }, + Thread { + request_id: Uuid, + data: ThreadEvent, + }, + Member { + request_id: Uuid, + data: MemberEvent, + }, + Reaction { + request_id: Uuid, + data: ReactionEvent, + }, + Poll { + request_id: Uuid, + data: PollEvent, + }, + Article { + request_id: Uuid, + data: ArticleEvent, + }, + Category { + request_id: Uuid, + data: CategoryEvent, + }, + Draft { + request_id: Uuid, + data: DraftEvent, + }, + Follow { + request_id: Uuid, + data: FollowEvent, + }, +} + +#[derive(Clone)] +pub struct ImEventBus { + tx: broadcast::Sender, + lagged: Arc, +} + +impl ImEventBus { + pub fn new(capacity: usize) -> Self { + let (tx, _) = broadcast::channel(capacity); + Self { + tx, + lagged: Arc::new(AtomicU64::new(0)), + } + } + + pub fn publish(&self, event: ImEvent) -> bool { + self.tx.send(event).is_ok() + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } + + pub fn record_lagged(&self, count: u64) { + self.lagged.fetch_add(count, Ordering::Relaxed); + } + + pub fn lagged_total(&self) -> u64 { + self.lagged.load(Ordering::Relaxed) + } +} + +impl Default for ImEventBus { + fn default() -> Self { + Self::new(1024) + } +} diff --git a/service/im/follows.rs b/service/im/follows.rs new file mode 100644 index 0000000..7638b07 --- /dev/null +++ b/service/im/follows.rs @@ -0,0 +1,232 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::immediate::{FollowAction, FollowEvent}; +use crate::models::channels::{ArticleCrossPost, ChannelFollow}; + +use crate::service::ImService; +use crate::service::im::events::ImEvent; + +use super::session::ImSession; +use super::util::*; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct FollowChannelParams { + pub target_workspace_id: Uuid, + pub target_channel_id: Option, + pub webhook_url: Option, +} + +impl ImService { + async fn follow_realtime(&self, channel_id: Uuid, follow_id: Uuid, action: FollowAction) { + let request_id = Uuid::nil(); + let event = FollowEvent { + channel_id, + follow_id, + action, + }; + self.publish(&format!("im.follow.{channel_id}"), request_id, &event) + .await; + self.emit_event(ImEvent::Follow { + request_id, + data: event, + }); + } + + pub async fn follow_list( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + ) -> Result, AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_admin(user_uid, &channel).await?; + + sqlx::query_as::<_, ChannelFollow>( + "SELECT id, source_channel_id, target_workspace_id, target_channel_id, \ + webhook_url, webhook_secret_ciphertext, enabled, followed_by, \ + unfollowed_at, last_delivery_at, last_delivery_status, created_at, updated_at \ + FROM channel_follow WHERE source_channel_id = $1 AND unfollowed_at IS NULL \ + ORDER BY created_at DESC", + ) + .bind(channel_id) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn follow_create( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + params: FollowChannelParams, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + let now = chrono::Utc::now(); + let follow = sqlx::query_as::<_, ChannelFollow>( + "INSERT INTO channel_follow \ + (id, source_channel_id, target_workspace_id, target_channel_id, \ + webhook_url, enabled, followed_by, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, true, $6, $7, $7) \ + ON CONFLICT (source_channel_id, target_workspace_id, target_channel_id) \ + DO UPDATE SET enabled = true, unfollowed_at = NULL, updated_at = $7 \ + RETURNING id, source_channel_id, target_workspace_id, target_channel_id, \ + webhook_url, webhook_secret_ciphertext, enabled, followed_by, \ + unfollowed_at, last_delivery_at, last_delivery_status, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(channel_id) + .bind(params.target_workspace_id) + .bind(params.target_channel_id) + .bind(params.webhook_url.as_deref()) + .bind(user_uid) + .bind(now) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + self.follow_realtime(channel_id, follow.id, FollowAction::Created) + .await; + Ok(follow) + } + + pub async fn follow_delete( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + follow_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_admin(user_uid, &channel).await?; + + let now = chrono::Utc::now(); + let result = sqlx::query( + "UPDATE channel_follow SET unfollowed_at = $1, enabled = false, updated_at = $1 \ + WHERE id = $2 AND source_channel_id = $3 AND unfollowed_at IS NULL", + ) + .bind(now) + .bind(follow_id) + .bind(channel_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + ensure_affected(result.rows_affected(), "follow not found")?; + self.follow_realtime(channel_id, follow_id, FollowAction::Deleted) + .await; + Ok(()) + } + + pub(crate) async fn cross_post_article( + &self, + article_id: Uuid, + channel_id: Uuid, + _actor_id: Uuid, + ) -> Result { + let followers = sqlx::query_as::<_, ChannelFollow>( + "SELECT id, source_channel_id, target_workspace_id, target_channel_id, \ + webhook_url, webhook_secret_ciphertext, enabled, followed_by, \ + unfollowed_at, last_delivery_at, last_delivery_status, created_at, updated_at \ + FROM channel_follow WHERE source_channel_id = $1 AND enabled AND unfollowed_at IS NULL", + ) + .bind(channel_id) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let now = chrono::Utc::now(); + let mut count = 0u64; + + for follow in &followers { + sqlx::query( + "INSERT INTO article_cross_post \ + (id, article_id, follow_id, target_workspace_id, target_channel_id, \ + status, attempts, created_at) \ + VALUES ($1, $2, $3, $4, $5, 'pending', 0, $6) \ + ON CONFLICT DO NOTHING", + ) + .bind(Uuid::now_v7()) + .bind(article_id) + .bind(follow.id) + .bind(follow.target_workspace_id) + .bind(follow.target_channel_id) + .bind(now) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + count += 1; + } + + if count > 0 { + sqlx::query("UPDATE article SET cross_posted = true WHERE id = $1") + .bind(article_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + } + + tracing::info!( + article_id = %article_id, + followers = count, + "Cross-post jobs created" + ); + Ok(count) + } + + pub async fn cross_post_list( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + article_id: Uuid, + ) -> Result, AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_admin(user_uid, &channel).await?; + + sqlx::query_as::<_, ArticleCrossPost>( + "SELECT id, article_id, follow_id, target_workspace_id, target_channel_id, \ + status, attempts, last_error, sent_at, delivered_at, failed_at, created_at \ + FROM article_cross_post WHERE article_id = $1 ORDER BY created_at ASC", + ) + .bind(article_id) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn cross_post_retry( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + cross_post_id: Uuid, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_admin(user_uid, &channel).await?; + + let post = sqlx::query_as::<_, ArticleCrossPost>( + "UPDATE article_cross_post SET status = 'pending', attempts = 0, \ + last_error = NULL, failed_at = NULL \ + WHERE id = $1 AND status = 'failed' \ + RETURNING id, article_id, follow_id, target_workspace_id, target_channel_id, \ + status, attempts, last_error, sent_at, delivered_at, failed_at, created_at", + ) + .bind(cross_post_id) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + self.follow_realtime(channel_id, post.follow_id, FollowAction::Retried) + .await; + Ok(post) + } +} diff --git a/service/im/members.rs b/service/im/members.rs new file mode 100644 index 0000000..eb2b90b --- /dev/null +++ b/service/im/members.rs @@ -0,0 +1,410 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::immediate::{MemberAction, MemberEvent}; +use crate::models::channels::ChannelMember; +use crate::models::common::Role; +use crate::models::workspaces::Workspace; +use crate::service::ImService; +use crate::service::im::events::ImEvent; + +use super::session::ImSession; +use super::util::*; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct InviteMemberParams { + pub user_id: Uuid, + pub role: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateMemberParams { + pub role: Option, + pub muted: Option, + pub pinned: Option, +} + +impl ImService { + pub async fn member_list( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + sqlx::query_as::<_, ChannelMember>( + "SELECT id, channel_id, user_id, role, status, muted, pinned, \ + last_read_message_id, last_read_at, joined_at, left_at, created_at, updated_at \ + FROM channel_member WHERE channel_id = $1 AND status = 'active' \ + ORDER BY joined_at ASC LIMIT $2 OFFSET $3", + ) + .bind(channel_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn member_invite( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + params: InviteMemberParams, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_editable(user_uid, &channel).await?; + + let ws = Workspace::find_by_id(self.ctx.db.reader(), channel.workspace_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("workspace not found".into()))?; + let ws_role = + Workspace::user_role(self.ctx.db.reader(), ws.id, params.user_id, ws.owner_id) + .await + .map_err(AppError::Database)?; + if ws_role == Some(Role::Unknown) || ws_role.is_none() { + return Err(AppError::BadRequest( + "invited user is not a workspace member".into(), + )); + } + + let is_already = self.is_channel_member(channel_id, params.user_id).await?; + if is_already { + return Err(AppError::Conflict("user is already a member".into())); + } + + let role = parse_enum(params.role, Role::Member, Role::Unknown, "role")?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let member = sqlx::query_as::<_, ChannelMember>( + "INSERT INTO channel_member \ + (id, channel_id, user_id, role, status, muted, pinned, joined_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, 'active', false, false, $5, $5, $5) \ + RETURNING id, channel_id, user_id, role, status, muted, pinned, \ + last_read_message_id, last_read_at, joined_at, left_at, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(channel_id) + .bind(params.user_id) + .bind(role) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + self.update_channel_stats(channel_id, now, &mut txn).await?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + tracing::info!(channel_id = %channel_id, user_id = %params.user_id, "Member invited"); + let request_id = Uuid::nil(); + let event = MemberEvent { + channel_id, + user_id: member.user_id, + action: MemberAction::Joined, + }; + self.publish(&format!("im.member.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Member { + request_id, + data: event, + }); + Ok(member) + } + + pub async fn member_update( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + member_user_id: Uuid, + params: UpdateMemberParams, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_admin(user_uid, &channel).await?; + + let role = match params.role { + Some(ref v) => parse_enum(Some(v.clone()), Role::Member, Role::Unknown, "role")?, + None => { + // Fetch current role + sqlx::query_scalar::<_, String>( + "SELECT role::text FROM channel_member \ + WHERE channel_id = $1 AND user_id = $2 AND status = 'active'", + ) + .bind(channel_id) + .bind(member_user_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .map(|s| s.parse::().unwrap_or(Role::Member)) + .unwrap_or(Role::Member) + } + }; + + let now = chrono::Utc::now(); + let member = sqlx::query_as::<_, ChannelMember>( + "UPDATE channel_member SET role = $1, muted = COALESCE($2, muted), \ + pinned = COALESCE($3, pinned), updated_at = $4 \ + WHERE channel_id = $5 AND user_id = $6 AND status = 'active' \ + RETURNING id, channel_id, user_id, role, status, muted, pinned, \ + last_read_message_id, last_read_at, joined_at, left_at, created_at, updated_at", + ) + .bind(role) + .bind(params.muted) + .bind(params.pinned) + .bind(now) + .bind(channel_id) + .bind(member_user_id) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + let request_id = Uuid::nil(); + let event = MemberEvent { + channel_id, + user_id: member.user_id, + action: MemberAction::Updated, + }; + self.publish(&format!("im.member.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Member { + request_id, + data: event, + }); + Ok(member) + } + + pub async fn member_kick( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + member_user_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_admin(user_uid, &channel).await?; + + if member_user_id == channel.created_by { + return Err(AppError::Forbidden("cannot kick channel owner".into())); + } + + let ws = Workspace::find_by_id(self.ctx.db.reader(), channel.workspace_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("workspace not found".into()))?; + if member_user_id == ws.owner_id { + return Err(AppError::Forbidden("cannot kick workspace owner".into())); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE channel_member SET status = 'inactive', left_at = $1, updated_at = $1 \ + WHERE channel_id = $2 AND user_id = $3 AND status = 'active'", + ) + .bind(now) + .bind(channel_id) + .bind(member_user_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "member not found")?; + + self.update_channel_stats(channel_id, now, &mut txn).await?; + txn.commit().await.map_err(|_| AppError::TxnError)?; + + tracing::info!(channel_id = %channel_id, user_id = %member_user_id, "Member kicked"); + let request_id = Uuid::nil(); + let event = MemberEvent { + channel_id, + user_id: member_user_id, + action: MemberAction::Kicked, + }; + self.publish(&format!("im.member.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Member { + request_id, + data: event, + }); + Ok(()) + } + + pub async fn member_leave( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + + if channel.created_by == user_uid { + return Err(AppError::Forbidden("channel owner cannot leave".into())); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE channel_member SET status = 'inactive', left_at = $1, updated_at = $1 \ + WHERE channel_id = $2 AND user_id = $3 AND status = 'active'", + ) + .bind(now) + .bind(channel_id) + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "not a member")?; + + self.update_channel_stats(channel_id, now, &mut txn).await?; + txn.commit().await.map_err(|_| AppError::TxnError)?; + let request_id = Uuid::nil(); + let event = MemberEvent { + channel_id, + user_id: user_uid, + action: MemberAction::Left, + }; + self.publish(&format!("im.member.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Member { + request_id, + data: event, + }); + Ok(()) + } + + pub async fn member_join( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + let is_already = self.is_channel_member(channel_id, user_uid).await?; + if is_already { + return Err(AppError::Conflict("already a member".into())); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let member = sqlx::query_as::<_, ChannelMember>( + "INSERT INTO channel_member \ + (id, channel_id, user_id, role, status, muted, pinned, joined_at, created_at, updated_at) \ + VALUES ($1, $2, $3, 'member', 'active', false, false, $4, $4, $4) \ + RETURNING id, channel_id, user_id, role, status, muted, pinned, \ + last_read_message_id, last_read_at, joined_at, left_at, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(channel_id) + .bind(user_uid) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + self.update_channel_stats(channel_id, now, &mut txn).await?; + txn.commit().await.map_err(|_| AppError::TxnError)?; + let request_id = Uuid::nil(); + let event = MemberEvent { + channel_id, + user_id: member.user_id, + action: MemberAction::Joined, + }; + self.publish(&format!("im.member.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Member { + request_id, + data: event, + }); + Ok(member) + } + + pub async fn member_update_read( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + message_id: Uuid, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + let now = chrono::Utc::now(); + sqlx::query_as::<_, ChannelMember>( + "UPDATE channel_member SET last_read_message_id = $1, last_read_at = $2, updated_at = $2 \ + WHERE channel_id = $3 AND user_id = $4 AND status = 'active' \ + RETURNING id, channel_id, user_id, role, status, muted, pinned, \ + last_read_message_id, last_read_at, joined_at, left_at, created_at, updated_at", + ) + .bind(message_id) + .bind(now) + .bind(channel_id) + .bind(user_uid) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database) + } +} diff --git a/service/im/messages.rs b/service/im/messages.rs new file mode 100644 index 0000000..6d4c5b4 --- /dev/null +++ b/service/im/messages.rs @@ -0,0 +1,889 @@ +use std::sync::OnceLock; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::immediate::{MessageAction, MessageEvent}; +use crate::models::channels::{Message, MessageBookmark, MessageEditHistory, SavedMessage}; +use crate::models::common::{JsonValue, MessageType}; +use crate::service::ImService; +use crate::service::im::delivery_trace::trace_message; +use crate::service::im::events::ImEvent; +use ::redis::Cmd; + +use super::session::ImSession; +use super::util::*; + +const MESSAGE_SEQ_SCRIPT: &str = "local cur = redis.call('GET', KEYS[1]); if (not cur) or (tonumber(cur) < tonumber(ARGV[1])) then redis.call('SET', KEYS[1], ARGV[1]); end; return redis.call('INCR', KEYS[1]);"; +static MESSAGE_SEQ_SHA: OnceLock = OnceLock::new(); + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct SendMessageParams { + pub body: String, + pub message_type: Option, + pub thread_id: Option, + pub reply_to_message_id: Option, + pub pinned: Option, + pub attachments: Option>, + pub embeds: Option>, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct EditMessageParams { + pub body: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateAttachmentParams { + pub filename: String, + pub url: String, + pub proxy_url: Option, + pub size_bytes: i64, + pub mime_type: String, + pub width: Option, + pub height: Option, + pub duration_ms: Option, + pub thumbnail_url: Option, + pub blurhash: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateEmbedParams { + pub embed_type: Option, + pub title: Option, + pub description: Option, + pub url: Option, + pub author_name: Option, + pub author_url: Option, + pub author_icon_url: Option, + pub thumbnail_url: Option, + pub image_url: Option, + pub color: Option, + pub fields: Option, + pub footer_text: Option, + pub footer_icon_url: Option, + pub provider_name: Option, + pub timestamp: Option>, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct MessageListFilters { + pub thread_id: Option, + pub author_id: Option, + pub pinned: Option, + pub before: Option, + pub after: Option, +} + +impl ImService { + pub async fn message_list( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + filters: MessageListFilters, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + sqlx::query_as::<_, Message>( + "SELECT id, channel_id, author_id, thread_id, reply_to_message_id, seq, \ + message_type, body, metadata, pinned, system, edited_at, deleted_at, \ + created_at, updated_at \ + FROM message \ + WHERE channel_id = $1 AND deleted_at IS NULL \ + AND ($2::uuid IS NULL OR thread_id = $2) \ + AND ($3::uuid IS NULL OR author_id = $3) \ + AND ($4::bool IS NULL OR pinned = $4) \ + AND ($5::uuid IS NULL OR seq < (SELECT seq FROM message WHERE id = $5)) \ + AND ($6::uuid IS NULL OR seq > (SELECT seq FROM message WHERE id = $6)) \ + ORDER BY seq DESC LIMIT $7 OFFSET $8", + ) + .bind(channel_id) + .bind(filters.thread_id) + .bind(filters.author_id) + .bind(filters.pinned) + .bind(filters.before) + .bind(filters.after) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + #[tracing::instrument(skip(self, ctx, params))] + pub async fn message_send( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + params: SendMessageParams, + request_id: Uuid, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_member(user_uid, &channel).await?; + + if channel.read_only { + self.ensure_channel_editable(user_uid, &channel).await?; + } + + let body = required_text(params.body, "body")?; + if body.len() > MAX_MESSAGE_BODY { + return Err(AppError::BadRequest("message body too long".into())); + } + + let msg_type = parse_enum( + params.message_type, + MessageType::Text, + MessageType::Unknown, + "message_type", + )?; + let thread_id = params.thread_id; + if let Some(thread_id) = thread_id { + self.resolve_thread(thread_id, channel_id).await?; + } + + let now = chrono::Utc::now(); + let message_id = Uuid::now_v7(); + let seq = self.next_message_seq(channel_id).await?; + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let message = sqlx::query_as::<_, Message>( + "INSERT INTO message \ + (id, channel_id, author_id, thread_id, reply_to_message_id, seq, \ + message_type, body, metadata, pinned, system, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL, $9, false, $10, $10) \ + RETURNING id, channel_id, author_id, thread_id, reply_to_message_id, seq, \ + message_type, body, metadata, pinned, system, edited_at, deleted_at, \ + created_at, updated_at", + ) + .bind(message_id) + .bind(channel_id) + .bind(user_uid) + .bind(thread_id) + .bind(params.reply_to_message_id) + .bind(seq) + .bind(msg_type) + .bind(&body) + .bind(params.pinned.unwrap_or(false)) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + // Insert attachments + if let Some(attachments) = params.attachments { + for att in &attachments { + let att_filename = required_text(att.filename.clone(), "filename")?; + let att_url = required_text(att.url.clone(), "url")?; + let att_mime = required_text(att.mime_type.clone(), "mime_type")?; + sqlx::query( + "INSERT INTO message_attachment \ + (id, message_id, channel_id, filename, url, proxy_url, \ + size_bytes, mime_type, width, height, duration_ms, \ + thumbnail_url, blurhash, created_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)", + ) + .bind(Uuid::now_v7()) + .bind(message_id) + .bind(channel_id) + .bind(&att_filename) + .bind(&att_url) + .bind(att.proxy_url.as_deref()) + .bind(att.size_bytes) + .bind(&att_mime) + .bind(att.width) + .bind(att.height) + .bind(att.duration_ms) + .bind(att.thumbnail_url.as_deref()) + .bind(att.blurhash.as_deref()) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } + } + + // Insert embeds + if let Some(embeds) = params.embeds { + for emb in &embeds { + sqlx::query( + "INSERT INTO message_embed \ + (id, message_id, embed_type, title, description, url, \ + author_name, author_url, author_icon_url, thumbnail_url, \ + image_url, color, fields, footer_text, footer_icon_url, \ + provider_name, \"timestamp\", created_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, \ + $11, $12, $13, $14, $15, $16, $17, $18)", + ) + .bind(Uuid::now_v7()) + .bind(message_id) + .bind(emb.embed_type.as_deref().unwrap_or("rich")) + .bind(emb.title.as_deref()) + .bind(emb.description.as_deref()) + .bind(emb.url.as_deref()) + .bind(emb.author_name.as_deref()) + .bind(emb.author_url.as_deref()) + .bind(emb.author_icon_url.as_deref()) + .bind(emb.thumbnail_url.as_deref()) + .bind(emb.image_url.as_deref()) + .bind(emb.color) + .bind(emb.fields.clone()) + .bind(emb.footer_text.as_deref()) + .bind(emb.footer_icon_url.as_deref()) + .bind(emb.provider_name.as_deref()) + .bind(emb.timestamp) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } + } + + if let Some(thread_id) = thread_id { + sqlx::query( + "UPDATE message_thread SET replies_count = replies_count + 1, \ + participants_count = (SELECT COUNT(DISTINCT author_id)::int FROM message WHERE thread_id = $3 AND deleted_at IS NULL), \ + last_reply_message_id = $1, last_reply_at = $2, updated_at = $2 \ + WHERE id = $3 AND channel_id = $4", + ) + .bind(message_id) + .bind(now) + .bind(thread_id) + .bind(channel_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } + + // Update channel last_message + sqlx::query( + "UPDATE channel SET last_message_id = $1, last_message_at = $2, updated_at = $2 \ + WHERE id = $3", + ) + .bind(message_id) + .bind(now) + .bind(channel_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + self.update_channel_stats(channel_id, now, &mut txn).await?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + tracing::info!(message_id = %message_id, channel_id = %channel_id, "Message sent"); + trace_message( + "committed", + request_id, + channel_id, + message.id, + Some(message.seq), + ); + + let event = MessageEvent { + channel_id, + thread_id: message.thread_id, + message_id: message.id, + author_id: message.author_id, + action: MessageAction::Created, + body: Some(message.body.clone()), + seq: Some(message.seq), + }; + self.publish(&format!("im.message.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Message { + request_id, + data: event, + }); + + Ok(message) + } + + pub async fn message_edit( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + message_id: Uuid, + params: EditMessageParams, + request_id: Uuid, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + let body = required_text(params.body, "body")?; + if body.len() > MAX_MESSAGE_BODY { + return Err(AppError::BadRequest("message body too long".into())); + } + + let existing = self.resolve_message(message_id, channel_id).await?; + if existing.author_id != user_uid { + self.ensure_channel_admin(user_uid, &channel).await?; + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + // Save edit history + sqlx::query( + "INSERT INTO message_edit_history (id, message_id, channel_id, previous_body, edited_by, edited_at) \ + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(Uuid::now_v7()) + .bind(message_id) + .bind(channel_id) + .bind(&existing.body) + .bind(user_uid) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let updated = sqlx::query_as::<_, Message>( + "UPDATE message SET body = $1, edited_at = $2, updated_at = $2 \ + WHERE id = $3 AND channel_id = $4 AND deleted_at IS NULL \ + RETURNING id, channel_id, author_id, thread_id, reply_to_message_id, seq, \ + message_type, body, metadata, pinned, system, edited_at, deleted_at, \ + created_at, updated_at", + ) + .bind(&body) + .bind(now) + .bind(message_id) + .bind(channel_id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + + let event = MessageEvent { + channel_id, + thread_id: updated.thread_id, + message_id: updated.id, + author_id: updated.author_id, + action: MessageAction::Edited, + body: Some(updated.body.clone()), + seq: Some(updated.seq), + }; + self.publish(&format!("im.message.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Message { + request_id, + data: event, + }); + + Ok(updated) + } + + pub async fn message_delete( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + message_id: Uuid, + request_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + + let existing = self.resolve_message(message_id, channel_id).await?; + if existing.author_id != user_uid { + self.ensure_channel_admin(user_uid, &channel).await?; + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE message SET deleted_at = $1, updated_at = $1 \ + WHERE id = $2 AND channel_id = $3 AND deleted_at IS NULL", + ) + .bind(now) + .bind(message_id) + .bind(channel_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "message not found")?; + + self.update_channel_stats(channel_id, now, &mut txn).await?; + txn.commit().await.map_err(|_| AppError::TxnError)?; + + let event = MessageEvent { + channel_id, + thread_id: None, + message_id, + author_id: existing.author_id, + action: MessageAction::Deleted, + body: None, + seq: Some(existing.seq), + }; + self.publish(&format!("im.message.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Message { + request_id, + data: event, + }); + + Ok(()) + } + + pub async fn message_pin( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + message_id: Uuid, + request_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_editable(user_uid, &channel).await?; + let message = self.resolve_message(message_id, channel_id).await?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query("UPDATE message SET pinned = true, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL") + .bind(now) + .bind(message_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO message_pin (id, message_id, channel_id, pinned_by, pinned_at) \ + VALUES ($1, $2, $3, $4, $5) \ + ON CONFLICT (message_id) DO NOTHING", + ) + .bind(Uuid::now_v7()) + .bind(message_id) + .bind(channel_id) + .bind(user_uid) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + + let event = MessageEvent { + channel_id, + thread_id: None, + message_id, + author_id: ctx.user, + action: MessageAction::Pinned, + body: None, + seq: Some(message.seq), + }; + self.publish(&format!("im.message.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Message { + request_id, + data: event, + }); + + Ok(()) + } + + pub async fn message_unpin( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + message_id: Uuid, + request_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_editable(user_uid, &channel).await?; + let message = self.resolve_message(message_id, channel_id).await?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query("UPDATE message SET pinned = false, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL") + .bind(now) + .bind(message_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query("DELETE FROM message_pin WHERE message_id = $1") + .bind(message_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + + let event = MessageEvent { + channel_id, + thread_id: None, + message_id, + author_id: ctx.user, + action: MessageAction::Unpinned, + body: None, + seq: Some(message.seq), + }; + self.publish(&format!("im.message.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Message { + request_id, + data: event, + }); + + Ok(()) + } + + pub async fn message_list_pinned( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + ) -> Result, AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + sqlx::query_as::<_, Message>( + "SELECT m.id, m.channel_id, m.author_id, m.thread_id, m.reply_to_message_id, m.seq, \ + m.message_type, m.body, m.metadata, m.pinned, m.system, m.edited_at, m.deleted_at, \ + m.created_at, m.updated_at \ + FROM message m \ + JOIN message_pin mp ON mp.message_id = m.id \ + WHERE m.channel_id = $1 AND m.deleted_at IS NULL AND m.pinned \ + ORDER BY mp.pinned_at DESC", + ) + .bind(channel_id) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn message_edit_history( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + message_id: Uuid, + ) -> Result, AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + sqlx::query_as::<_, MessageEditHistory>( + "SELECT id, message_id, channel_id, previous_body, edited_by, edited_at \ + FROM message_edit_history \ + WHERE message_id = $1 AND channel_id = $2 \ + ORDER BY edited_at ASC", + ) + .bind(message_id) + .bind(channel_id) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn message_bookmark( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + message_id: Uuid, + note: Option, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + self.resolve_message(message_id, channel_id).await?; + + let now = chrono::Utc::now(); + sqlx::query_as::<_, MessageBookmark>( + "INSERT INTO message_bookmark (id, message_id, channel_id, user_id, note, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $6) \ + ON CONFLICT (message_id, user_id) DO UPDATE SET note = COALESCE($5, message_bookmark.note), updated_at = $6 \ + RETURNING id, message_id, channel_id, user_id, note, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(message_id) + .bind(channel_id) + .bind(user_uid) + .bind(note.as_deref()) + .bind(now) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database) + } + + pub async fn message_unbookmark( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + message_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + let result = sqlx::query( + "DELETE FROM message_bookmark WHERE message_id = $1 AND user_id = $2 AND channel_id = $3", + ) + .bind(message_id) + .bind(user_uid) + .bind(channel_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + ensure_affected(result.rows_affected(), "bookmark not found") + } + + pub async fn message_list_bookmarks( + &self, + ctx: &ImSession, + wk_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user; + let ws = self.resolve_workspace(wk_name).await?; + self.ensure_workspace_readable(user_uid, &ws).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + sqlx::query_as::<_, MessageBookmark>( + "SELECT mb.id, mb.message_id, mb.channel_id, mb.user_id, mb.note, mb.created_at, mb.updated_at \ + FROM message_bookmark mb \ + JOIN channel c ON c.id = mb.channel_id \ + WHERE mb.user_id = $1 AND c.workspace_id = $2 \ + ORDER BY mb.created_at DESC LIMIT $3 OFFSET $4", + ) + .bind(user_uid) + .bind(ws.id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn message_save( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + message_id: Uuid, + note: Option, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + self.resolve_message(message_id, channel_id).await?; + + let now = chrono::Utc::now(); + sqlx::query_as::<_, SavedMessage>( + "INSERT INTO saved_message (id, user_id, message_id, channel_id, note, created_at) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + ON CONFLICT (user_id, message_id) DO NOTHING \ + RETURNING id, user_id, message_id, channel_id, note, created_at", + ) + .bind(Uuid::now_v7()) + .bind(user_uid) + .bind(message_id) + .bind(channel_id) + .bind(note.as_deref()) + .bind(now) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database) + } + + pub async fn message_unsave( + &self, + ctx: &ImSession, + wk_name: &str, + message_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let ws = self.resolve_workspace(wk_name).await?; + self.ensure_workspace_readable(user_uid, &ws).await?; + + let result = + sqlx::query("DELETE FROM saved_message WHERE user_id = $1 AND message_id = $2") + .bind(user_uid) + .bind(message_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + ensure_affected(result.rows_affected(), "saved message not found") + } + + pub async fn message_list_saved( + &self, + ctx: &ImSession, + wk_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user; + let ws = self.resolve_workspace(wk_name).await?; + self.ensure_workspace_readable(user_uid, &ws).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + sqlx::query_as::<_, SavedMessage>( + "SELECT sm.id, sm.user_id, sm.message_id, sm.channel_id, sm.note, sm.created_at \ + FROM saved_message sm \ + JOIN channel c ON c.id = sm.channel_id \ + WHERE sm.user_id = $1 AND c.workspace_id = $2 \ + ORDER BY sm.created_at DESC LIMIT $3 OFFSET $4", + ) + .bind(user_uid) + .bind(ws.id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + async fn next_message_seq(&self, channel_id: Uuid) -> Result { + let key = format!("im:seq:{channel_id}"); + let mut conn = self.ctx.redis.get_connection()?; + let exists: bool = Cmd::new() + .arg("EXISTS") + .arg(&key) + .query(&mut *conn.inner_mut()) + .map_err(AppError::Redis)?; + let db_max = if exists { + 0 + } else { + sqlx::query_scalar( + "SELECT COALESCE(MAX(seq), 0)::bigint FROM message WHERE channel_id = $1", + ) + .bind(channel_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + }; + let sha = self.message_seq_sha()?; + let result: Result = Cmd::new() + .arg("EVALSHA") + .arg(&sha) + .arg(1) + .arg(&key) + .arg(db_max) + .query(&mut *conn.inner_mut()); + match result { + Ok(seq) => Ok(seq), + Err(e) if e.to_string().contains("NOSCRIPT") => Cmd::new() + .arg("EVAL") + .arg(MESSAGE_SEQ_SCRIPT) + .arg(1) + .arg(&key) + .arg(db_max) + .query(&mut *conn.inner_mut()) + .map_err(AppError::Redis), + Err(e) => Err(AppError::Redis(e)), + } + } + + fn message_seq_sha(&self) -> Result { + if let Some(sha) = MESSAGE_SEQ_SHA.get() { + return Ok(sha.clone()); + } + let mut conn = self.ctx.redis.get_connection()?; + let sha: String = Cmd::new() + .arg("SCRIPT") + .arg("LOAD") + .arg(MESSAGE_SEQ_SCRIPT) + .query(&mut *conn.inner_mut()) + .map_err(AppError::Redis)?; + let _ = MESSAGE_SEQ_SHA.set(sha.clone()); + Ok(sha) + } + + pub(crate) async fn resolve_message( + &self, + message_id: Uuid, + channel_id: Uuid, + ) -> Result { + sqlx::query_as::<_, Message>( + "SELECT id, channel_id, author_id, thread_id, reply_to_message_id, seq, \ + message_type, body, metadata, pinned, system, edited_at, deleted_at, \ + created_at, updated_at \ + FROM message WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL", + ) + .bind(message_id) + .bind(channel_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("message not found".into())) + } +} diff --git a/service/im/mod.rs b/service/im/mod.rs new file mode 100644 index 0000000..d13f951 --- /dev/null +++ b/service/im/mod.rs @@ -0,0 +1,58 @@ +use std::sync::Arc; + +use serde::Serialize; +use uuid::Uuid; + +use crate::service::ServiceContext; +use delivery_trace::{trace_error, trace_request}; +use events::ImEvent; + +pub mod articles; +pub mod categories; +pub mod channels; +pub mod delivery_trace; +pub mod drafts; +pub mod events; +pub mod follows; +pub mod members; +pub mod messages; +pub mod polls; +pub mod presence; +pub mod reactions; +pub mod session; +pub mod threads; +pub mod util; + +pub use messages::{EditMessageParams, SendMessageParams}; +pub use presence::UpdatePresenceParams; +pub use session::ImSession; + +#[derive(Clone)] +pub struct ImService { + pub ctx: Arc, +} + +impl ImService { + fn emit_event(&self, event: ImEvent) { + let _ = self.ctx.im_events.publish(event); + } + + async fn publish(&self, subject: &str, request_id: Uuid, event: &T) { + match self + .ctx + .nats + .publish_with_headers( + subject, + &serde_json::to_vec(event).unwrap_or_default(), + vec![("X-Request-Id".into(), request_id.to_string())], + ) + .await + { + Ok(_) => trace_request("nats_published", request_id, subject), + Err(e) => { + trace_error("nats_failed", request_id, subject, &e); + tracing::warn!(subject, error = %e, "nats publish failed"); + } + } + } +} diff --git a/service/im/polls.rs b/service/im/polls.rs new file mode 100644 index 0000000..b917ae7 --- /dev/null +++ b/service/im/polls.rs @@ -0,0 +1,372 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::immediate::{PollAction, PollEvent}; +use crate::models::channels::{MessagePoll, MessagePollOption, MessagePollVote}; +use crate::models::common::PollLayout; +use crate::service::ImService; +use crate::service::im::events::ImEvent; + +use super::session::ImSession; +use super::util::*; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreatePollParams { + pub question: String, + pub description: Option, + pub options: Vec, + pub layout: Option, + pub allow_multiselect: Option, + pub duration_hours: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreatePollOptionParams { + pub text: String, + pub emoji_id: Option, + pub emoji_name: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct VoteParams { + pub option_ids: Vec, +} + +impl ImService { + pub async fn poll_create( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + message_id: Uuid, + params: CreatePollParams, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + self.resolve_message(message_id, channel_id).await?; + + let question = required_text(params.question, "question")?; + if params.options.is_empty() || params.options.len() > MAX_POLL_OPTIONS { + return Err(AppError::BadRequest(format!( + "poll must have between 1 and {MAX_POLL_OPTIONS} options" + ))); + } + + let layout = parse_enum( + params.layout, + PollLayout::Default, + PollLayout::Unknown, + "layout", + )?; + + let now = chrono::Utc::now(); + let poll_id = Uuid::now_v7(); + let ends_at = params + .duration_hours + .map(|h| now + chrono::Duration::hours(h as i64)); + + let validated_options: Vec<(String, Option, Option)> = params + .options + .iter() + .map(|opt| { + let text = required_text(opt.text.clone(), "option text")?; + if text.len() > MAX_POLL_OPTION_TEXT { + return Err(AppError::BadRequest("poll option text too long".into())); + } + Ok((text, opt.emoji_id.clone(), opt.emoji_name.clone())) + }) + .collect::, AppError>>()?; + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let poll = sqlx::query_as::<_, MessagePoll>( + "INSERT INTO message_poll \ + (id, message_id, channel_id, question, description, layout, \ + allow_multiselect, duration_hours, ends_at, total_votes, metadata, \ + created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, NULL, $10, $10) \ + RETURNING id, message_id, channel_id, question, description, layout, \ + allow_multiselect, duration_hours, ends_at, total_votes, metadata, \ + created_at, updated_at", + ) + .bind(poll_id) + .bind(message_id) + .bind(channel_id) + .bind(&question) + .bind(params.description.as_deref()) + .bind(layout) + .bind(params.allow_multiselect.unwrap_or(false)) + .bind(params.duration_hours) + .bind(ends_at) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + for (i, (text, emoji_id, emoji_name)) in validated_options.iter().enumerate() { + sqlx::query( + "INSERT INTO message_poll_option \ + (id, poll_id, position, text, emoji_id, emoji_name, vote_count, created_at) \ + VALUES ($1, $2, $3, $4, $5, $6, 0, $7)", + ) + .bind(Uuid::now_v7()) + .bind(poll_id) + .bind(i as i32) + .bind(text) + .bind(emoji_id.as_deref()) + .bind(emoji_name.as_deref()) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } + + txn.commit().await.map_err(|_| AppError::TxnError)?; + tracing::info!(poll_id = %poll_id, "Poll created"); + let request_id = Uuid::nil(); + let event = PollEvent { + channel_id, + poll_id, + action: PollAction::Created, + }; + self.publish(&format!("im.poll.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Poll { + request_id, + data: event, + }); + Ok(poll) + } + + pub async fn poll_get( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + poll_id: Uuid, + ) -> Result<(MessagePoll, Vec), AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + let poll = sqlx::query_as::<_, MessagePoll>( + "SELECT id, message_id, channel_id, question, description, layout, \ + allow_multiselect, duration_hours, ends_at, total_votes, metadata, \ + created_at, updated_at \ + FROM message_poll WHERE id = $1 AND channel_id = $2", + ) + .bind(poll_id) + .bind(channel_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("poll not found".into()))?; + + let options = sqlx::query_as::<_, MessagePollOption>( + "SELECT id, poll_id, position, text, emoji_id, emoji_name, vote_count, created_at \ + FROM message_poll_option WHERE poll_id = $1 ORDER BY position ASC", + ) + .bind(poll_id) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + Ok((poll, options)) + } + + pub async fn poll_vote( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + poll_id: Uuid, + params: VoteParams, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + let poll = sqlx::query_as::<_, MessagePoll>( + "SELECT id, message_id, channel_id, question, description, layout, \ + allow_multiselect, duration_hours, ends_at, total_votes, metadata, \ + created_at, updated_at \ + FROM message_poll WHERE id = $1 AND channel_id = $2", + ) + .bind(poll_id) + .bind(channel_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("poll not found".into()))?; + + if let Some(ends) = poll.ends_at + && chrono::Utc::now() > ends + { + return Err(AppError::BadRequest("poll has ended".into())); + } + + if !poll.allow_multiselect && params.option_ids.len() > 1 { + return Err(AppError::BadRequest("multiselect not allowed".into())); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + // Collect old option_ids before deleting + let old_option_ids: Vec = sqlx::query_scalar( + "DELETE FROM message_poll_vote WHERE poll_id = $1 AND user_id = $2 RETURNING option_id", + ) + .bind(poll_id) + .bind(user_uid) + .fetch_all(&mut *txn) + .await + .map_err(AppError::Database)?; + + let removed = old_option_ids.len() as i32; + + // Decrement old vote counts + for opt_id in &old_option_ids { + sqlx::query( + "UPDATE message_poll_option SET vote_count = GREATEST(vote_count - 1, 0) WHERE id = $1", + ) + .bind(opt_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } + + // Insert new votes + let mut new_count = 0i32; + for option_id in ¶ms.option_ids { + sqlx::query( + "INSERT INTO message_poll_vote (id, poll_id, option_id, user_id, voted_at) \ + VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING", + ) + .bind(Uuid::now_v7()) + .bind(poll_id) + .bind(option_id) + .bind(user_uid) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE message_poll_option SET vote_count = vote_count + 1 \ + WHERE id = $1 AND poll_id = $2", + ) + .bind(option_id) + .bind(poll_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + new_count += 1; + } + + let delta = new_count - removed; + sqlx::query( + "UPDATE message_poll SET total_votes = total_votes + $1, updated_at = $2 WHERE id = $3", + ) + .bind(delta) + .bind(now) + .bind(poll_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + let request_id = Uuid::nil(); + let event = PollEvent { + channel_id, + poll_id, + action: PollAction::Voted, + }; + self.publish(&format!("im.poll.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Poll { + request_id, + data: event, + }); + Ok(()) + } + + pub async fn poll_results( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + poll_id: Uuid, + ) -> Result, AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + sqlx::query_as::<_, MessagePollVote>( + "SELECT id, poll_id, option_id, user_id, voted_at \ + FROM message_poll_vote WHERE poll_id = $1 ORDER BY voted_at ASC", + ) + .bind(poll_id) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn poll_delete( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + poll_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_editable(user_uid, &channel).await?; + + let result = sqlx::query("DELETE FROM message_poll WHERE id = $1 AND channel_id = $2") + .bind(poll_id) + .bind(channel_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + ensure_affected(result.rows_affected(), "poll not found")?; + let request_id = Uuid::nil(); + let event = PollEvent { + channel_id, + poll_id, + action: PollAction::Deleted, + }; + self.publish(&format!("im.poll.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Poll { + request_id, + data: event, + }); + Ok(()) + } +} diff --git a/service/im/presence.rs b/service/im/presence.rs new file mode 100644 index 0000000..225bc2a --- /dev/null +++ b/service/im/presence.rs @@ -0,0 +1,244 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::immediate::{PresenceEvent, TypingEvent}; +use crate::models::common::PresenceStatus; +use crate::models::users::UserPresence; +use crate::service::ImService; +use crate::service::im::events::ImEvent; + +use super::session::ImSession; +use super::util::*; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdatePresenceParams { + pub status: String, + pub custom_status_text: Option, + pub custom_status_emoji: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct TypingParams { + pub channel_id: Uuid, + pub thread_id: Option, +} + +impl ImService { + pub async fn presence_update( + &self, + ctx: &ImSession, + wk_name: &str, + params: UpdatePresenceParams, + ) -> Result { + let user_uid = ctx.user; + let _ = self.resolve_workspace(wk_name).await?; + + let status = parse_enum( + Some(params.status), + PresenceStatus::Online, + PresenceStatus::Unknown, + "status", + )?; + + let now = chrono::Utc::now(); + + let presence = sqlx::query_as::<_, UserPresence>( + "INSERT INTO user_presence (id, user_id, status, custom_status_text, custom_status_emoji, \ + last_active_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $6, $6) \ + ON CONFLICT (user_id) DO UPDATE SET \ + status = $3, custom_status_text = $4, custom_status_emoji = $5, \ + last_active_at = $6, updated_at = $6 \ + RETURNING id, user_id, status, custom_status_text, custom_status_emoji, \ + device_type, ip_address, last_active_at, last_seen_at, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(user_uid) + .bind(status) + .bind(params.custom_status_text.as_deref()) + .bind(params.custom_status_emoji.as_deref()) + .bind(now) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + // Cache in Redis for fast lookup + let key = format!("{PRESENCE_PREFIX}{user_uid}"); + if let Ok(mut conn) = self.ctx.redis.get_connection() { + let _ = redis::cmd("SETEX") + .arg(&key) + .arg(PRESENCE_TTL_SECS as u64) + .arg(status.to_string()) + .query::<()>(&mut *conn.inner_mut()); + } + + let request_id = Uuid::nil(); + let event = PresenceEvent { + user_id: user_uid, + status: presence.status.to_string(), + custom_status_text: presence.custom_status_text.clone(), + custom_status_emoji: presence.custom_status_emoji.clone(), + }; + self.publish(&format!("im.presence.{}", user_uid), request_id, &event) + .await; + self.emit_event(ImEvent::Presence { + request_id, + data: event, + }); + Ok(presence) + } + + pub async fn presence_get( + &self, + ctx: &ImSession, + wk_name: &str, + user_id: Uuid, + ) -> Result, AppError> { + let user_uid = ctx.user; + let ws = self.resolve_workspace(wk_name).await?; + self.ensure_workspace_readable(user_uid, &ws).await?; + + // Try DB first (has full record) + if let Some(p) = sqlx::query_as::<_, UserPresence>( + "SELECT id, user_id, status, custom_status_text, custom_status_emoji, \ + device_type, ip_address, last_active_at, last_seen_at, created_at, updated_at \ + FROM user_presence WHERE user_id = $1", + ) + .bind(user_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + { + return Ok(Some(p)); + } + + // Fallback: check Redis for a cached status + let key = format!("{PRESENCE_PREFIX}{user_id}"); + if let Ok(mut conn) = self.ctx.redis.get_connection() { + let cached: Option = redis::cmd("GET") + .arg(&key) + .query(&mut *conn.inner_mut()) + .ok() + .flatten(); + + if let Some(status_str) = cached + && let Ok(status) = status_str.parse::() + { + let now = chrono::Utc::now(); + return Ok(Some(UserPresence { + id: Uuid::nil(), + user_id, + status, + custom_status_text: None, + custom_status_emoji: None, + device_type: None, + ip_address: None, + last_active_at: now, + last_seen_at: None, + created_at: now, + updated_at: now, + })); + } + } + + Ok(None) + } + + pub async fn presence_heartbeat(&self, ctx: &ImSession, wk_name: &str) -> Result<(), AppError> { + let user_uid = ctx.user; + let ws = self.resolve_workspace(wk_name).await?; + self.ensure_workspace_readable(user_uid, &ws).await?; + + let key = format!("{PRESENCE_PREFIX}{user_uid}"); + if let Ok(mut conn) = self.ctx.redis.get_connection() + && let Err(e) = redis::cmd("SETEX") + .arg(&key) + .arg(PRESENCE_TTL_SECS as u64) + .arg("online") + .query::<()>(&mut *conn.inner_mut()) + { + tracing::warn!(error = %e, "redis presence heartbeat failed"); + } + + let now = chrono::Utc::now(); + if let Err(e) = sqlx::query( + "UPDATE user_presence SET last_active_at = $1, updated_at = $1 WHERE user_id = $2", + ) + .bind(now) + .bind(user_uid) + .execute(self.ctx.db.writer()) + .await + { + tracing::warn!(error = %e, "db presence heartbeat failed"); + } + + Ok(()) + } + + pub async fn typing_start( + &self, + ctx: &ImSession, + wk_name: &str, + params: TypingParams, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let ws = self.resolve_workspace(wk_name).await?; + self.ensure_workspace_readable(user_uid, &ws).await?; + + let channel = self.resolve_channel(params.channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + let key = typing_key(params.channel_id, params.thread_id, user_uid); + let mut conn = self.ctx.redis.get_connection()?; + redis::cmd("SETEX") + .arg(&key) + .arg(TYPING_TTL_SECS as u64) + .arg("1") + .query::<()>(&mut *conn.inner_mut())?; + + let request_id = Uuid::nil(); + let event = TypingEvent { + channel_id: params.channel_id, + thread_id: params.thread_id, + user_id: user_uid, + }; + self.publish( + &format!("im.typing.{}", params.channel_id), + request_id, + &event, + ) + .await; + self.emit_event(ImEvent::Typing { + request_id, + data: event, + }); + Ok(()) + } + + pub async fn typing_stop( + &self, + ctx: &ImSession, + wk_name: &str, + params: TypingParams, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let ws = self.resolve_workspace(wk_name).await?; + self.ensure_workspace_readable(user_uid, &ws).await?; + + let key = typing_key(params.channel_id, params.thread_id, user_uid); + let mut conn = self.ctx.redis.get_connection()?; + redis::cmd("DEL") + .arg(&key) + .query::<()>(&mut *conn.inner_mut())?; + + Ok(()) + } +} + +fn typing_key(channel_id: Uuid, thread_id: Option, user_id: Uuid) -> String { + match thread_id { + Some(tid) => format!("{TYPING_PREFIX}{channel_id}:{tid}:{user_id}"), + None => format!("{TYPING_PREFIX}{channel_id}:{user_id}"), + } +} diff --git a/service/im/reactions.rs b/service/im/reactions.rs new file mode 100644 index 0000000..4401844 --- /dev/null +++ b/service/im/reactions.rs @@ -0,0 +1,261 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::immediate::{ReactionAction, ReactionEvent}; +use crate::models::channels::{MessageMention, MessageReaction}; +use crate::service::ImService; +use crate::service::im::events::ImEvent; + +use super::session::ImSession; +use super::util::*; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct AddReactionParams { + pub content: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct AddMentionParams { + pub mentioned_user_id: Uuid, +} + +impl ImService { + pub async fn reaction_list( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + message_id: Uuid, + ) -> Result, AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + sqlx::query_as::<_, MessageReaction>( + "SELECT id, message_id, channel_id, user_id, content, created_at \ + FROM message_reaction WHERE message_id = $1 AND channel_id = $2 \ + ORDER BY created_at ASC", + ) + .bind(message_id) + .bind(channel_id) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn reaction_add( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + message_id: Uuid, + params: AddReactionParams, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + self.resolve_message(message_id, channel_id).await?; + + let content = required_text(params.content, "content")?; + let now = chrono::Utc::now(); + + let reaction = sqlx::query_as::<_, MessageReaction>( + "INSERT INTO message_reaction (id, message_id, channel_id, user_id, content, created_at) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + ON CONFLICT (message_id, user_id, content) DO NOTHING \ + RETURNING id, message_id, channel_id, user_id, content, created_at", + ) + .bind(Uuid::now_v7()) + .bind(message_id) + .bind(channel_id) + .bind(user_uid) + .bind(&content) + .bind(now) + .fetch_optional(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + if reaction.is_none() { + return Err(AppError::Conflict("reaction already exists".into())); + } + + let reaction = reaction.unwrap(); + let request_id = Uuid::nil(); + let event = ReactionEvent { + channel_id, + message_id, + user_id: reaction.user_id, + action: ReactionAction::Added, + content: Some(reaction.content.clone()), + }; + self.publish(&format!("im.reaction.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Reaction { + request_id, + data: event, + }); + Ok(reaction) + } + + pub async fn reaction_remove( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + message_id: Uuid, + content: &str, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + + let result = sqlx::query( + "DELETE FROM message_reaction \ + WHERE message_id = $1 AND channel_id = $2 AND user_id = $3 AND content = $4", + ) + .bind(message_id) + .bind(channel_id) + .bind(user_uid) + .bind(content) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + ensure_affected(result.rows_affected(), "reaction not found")?; + let request_id = Uuid::nil(); + let event = ReactionEvent { + channel_id, + message_id, + user_id: user_uid, + action: ReactionAction::Removed, + content: Some(content.to_string()), + }; + self.publish(&format!("im.reaction.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Reaction { + request_id, + data: event, + }); + Ok(()) + } + + pub async fn reaction_remove_all( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + message_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_admin(user_uid, &channel).await?; + + sqlx::query("DELETE FROM message_reaction WHERE message_id = $1 AND channel_id = $2") + .bind(message_id) + .bind(channel_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + let request_id = Uuid::nil(); + let event = ReactionEvent { + channel_id, + message_id, + user_id: user_uid, + action: ReactionAction::Removed, + content: None, + }; + self.publish(&format!("im.reaction.{}", channel_id), request_id, &event) + .await; + self.emit_event(ImEvent::Reaction { + request_id, + data: event, + }); + Ok(()) + } + + pub async fn mention_list_for_user( + &self, + ctx: &ImSession, + wk_name: &str, + limit: i64, + offset: i64, + unread_only: bool, + ) -> Result, AppError> { + let user_uid = ctx.user; + let _ = self.resolve_workspace(wk_name).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + if unread_only { + sqlx::query_as::<_, MessageMention>( + "SELECT id, message_id, channel_id, mentioned_user_id, mentioned_by, read_at, created_at \ + FROM message_mention \ + WHERE mentioned_user_id = $1 AND read_at IS NULL \ + ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(user_uid) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } else { + sqlx::query_as::<_, MessageMention>( + "SELECT id, message_id, channel_id, mentioned_user_id, mentioned_by, read_at, created_at \ + FROM message_mention \ + WHERE mentioned_user_id = $1 \ + ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(user_uid) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + } + + pub async fn mention_mark_read( + &self, + ctx: &ImSession, + _wk_name: &str, + mention_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let now = chrono::Utc::now(); + + let result = sqlx::query( + "UPDATE message_mention SET read_at = $1 \ + WHERE id = $2 AND mentioned_user_id = $3 AND read_at IS NULL", + ) + .bind(now) + .bind(mention_id) + .bind(user_uid) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + ensure_affected(result.rows_affected(), "mention not found or already read") + } + + pub async fn mention_mark_all_read( + &self, + ctx: &ImSession, + wk_name: &str, + ) -> Result { + let user_uid = ctx.user; + let _ = self.resolve_workspace(wk_name).await?; + let now = chrono::Utc::now(); + + let result = sqlx::query( + "UPDATE message_mention SET read_at = $1 \ + WHERE mentioned_user_id = $2 AND read_at IS NULL", + ) + .bind(now) + .bind(user_uid) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + Ok(result.rows_affected()) + } +} diff --git a/service/im/session.rs b/service/im/session.rs new file mode 100644 index 0000000..cd7672f --- /dev/null +++ b/service/im/session.rs @@ -0,0 +1,12 @@ +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct ImSession { + pub user: Uuid, +} + +impl ImSession { + pub fn new(user: Uuid) -> Self { + Self { user } + } +} diff --git a/service/im/threads.rs b/service/im/threads.rs new file mode 100644 index 0000000..62fc7cd --- /dev/null +++ b/service/im/threads.rs @@ -0,0 +1,321 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::immediate::{ThreadAction, ThreadEvent}; +use crate::models::channels::MessageThread; +use crate::service::ImService; +use crate::service::im::events::ImEvent; + +use super::session::ImSession; +use super::util::*; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateThreadParams { + pub title: Option, + pub root_message_id: Uuid, + pub tags: Option>, + pub auto_archive_duration: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateThreadParams { + pub title: Option, + pub tags: Option>, + pub pinned: Option, + pub locked: Option, + pub rate_limit_per_user: Option, + pub resolved: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct ThreadListFilters { + pub pinned: Option, + pub locked: Option, + pub resolved: Option, +} + +impl ImService { + pub async fn thread_list( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + filters: ThreadListFilters, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + sqlx::query_as::<_, MessageThread>( + "SELECT id, channel_id, root_message_id, created_by, replies_count, \ + participants_count, last_reply_message_id, last_reply_at, resolved, \ + resolved_by, resolved_at, title, tags, pinned, locked, \ + rate_limit_per_user, auto_archive_at, created_at, updated_at \ + FROM message_thread WHERE channel_id = $1 \ + AND ($2::bool IS NULL OR pinned = $2) \ + AND ($3::bool IS NULL OR locked = $3) \ + AND ($4::bool IS NULL OR resolved = $4) \ + ORDER BY last_reply_at DESC NULLS LAST, created_at DESC \ + LIMIT $5 OFFSET $6", + ) + .bind(channel_id) + .bind(filters.pinned) + .bind(filters.locked) + .bind(filters.resolved) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn thread_get( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + thread_id: Uuid, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + self.resolve_thread(thread_id, channel_id).await + } + + pub async fn thread_create( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + params: CreateThreadParams, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + self.resolve_message(params.root_message_id, channel_id) + .await?; + + let now = chrono::Utc::now(); + let thread_id = Uuid::now_v7(); + let tags = params.tags.unwrap_or_default(); + let auto_archive_at = params + .auto_archive_duration + .map(|d| now + chrono::Duration::minutes(d as i64)); + + let thread = sqlx::query_as::<_, MessageThread>( + "INSERT INTO message_thread \ + (id, channel_id, root_message_id, created_by, replies_count, \ + participants_count, last_reply_message_id, last_reply_at, resolved, \ + title, tags, pinned, locked, auto_archive_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, 0, 0, NULL, NULL, false, $5, $6, false, false, $7, $8, $8) \ + RETURNING id, channel_id, root_message_id, created_by, replies_count, \ + participants_count, last_reply_message_id, last_reply_at, resolved, \ + resolved_by, resolved_at, title, tags, pinned, locked, \ + rate_limit_per_user, auto_archive_at, created_at, updated_at", + ) + .bind(thread_id) + .bind(channel_id) + .bind(params.root_message_id) + .bind(user_uid) + .bind(params.title.as_deref()) + .bind(&tags) + .bind(auto_archive_at) + .bind(now) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + tracing::info!(thread_id = %thread_id, channel_id = %channel_id, "Thread created"); + let request_id = Uuid::nil(); + let event = ThreadEvent { + channel_id, + thread_id, + action: ThreadAction::Created, + }; + self.publish( + &format!("im.thread.{}.{}", channel_id, thread_id), + request_id, + &event, + ) + .await; + self.emit_event(ImEvent::Thread { + request_id, + data: event, + }); + Ok(thread) + } + + pub async fn thread_update( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + thread_id: Uuid, + params: UpdateThreadParams, + ) -> Result { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + let thread = self.resolve_thread(thread_id, channel_id).await?; + + let is_owner = thread.created_by == user_uid; + if !is_owner { + self.ensure_channel_editable(user_uid, &channel).await?; + } + + let now = chrono::Utc::now(); + let resolved_by = if params.resolved == Some(true) && !thread.resolved { + Some(user_uid) + } else { + thread.resolved_by + }; + let resolved_at = if params.resolved == Some(true) && !thread.resolved { + Some(now) + } else if params.resolved == Some(false) { + None + } else { + thread.resolved_at + }; + + let updated = sqlx::query_as::<_, MessageThread>( + "UPDATE message_thread SET \ + title = COALESCE($1, title), \ + tags = COALESCE($2, tags), \ + pinned = COALESCE($3, pinned), \ + locked = COALESCE($4, locked), \ + rate_limit_per_user = COALESCE($5, rate_limit_per_user), \ + resolved = COALESCE($6, resolved), \ + resolved_by = $7, resolved_at = $8, \ + updated_at = $9 \ + WHERE id = $10 \ + RETURNING id, channel_id, root_message_id, created_by, replies_count, \ + participants_count, last_reply_message_id, last_reply_at, resolved, \ + resolved_by, resolved_at, title, tags, pinned, locked, \ + rate_limit_per_user, auto_archive_at, created_at, updated_at", + ) + .bind(params.title.as_deref()) + .bind(params.tags.as_deref()) + .bind(params.pinned) + .bind(params.locked) + .bind(params.rate_limit_per_user) + .bind(params.resolved) + .bind(resolved_by) + .bind(resolved_at) + .bind(now) + .bind(thread_id) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + let request_id = Uuid::nil(); + let event = ThreadEvent { + channel_id, + thread_id, + action: ThreadAction::Updated, + }; + self.publish( + &format!("im.thread.{}.{}", channel_id, thread_id), + request_id, + &event, + ) + .await; + self.emit_event(ImEvent::Thread { + request_id, + data: event, + }); + Ok(updated) + } + + pub async fn thread_delete( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + thread_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_admin(user_uid, &channel).await?; + + let result = sqlx::query("DELETE FROM message_thread WHERE id = $1 AND channel_id = $2") + .bind(thread_id) + .bind(channel_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + ensure_affected(result.rows_affected(), "thread not found")?; + let request_id = Uuid::nil(); + let event = ThreadEvent { + channel_id, + thread_id, + action: ThreadAction::Deleted, + }; + self.publish( + &format!("im.thread.{}.{}", channel_id, thread_id), + request_id, + &event, + ) + .await; + self.emit_event(ImEvent::Thread { + request_id, + data: event, + }); + Ok(()) + } + + pub async fn thread_read_state_update( + &self, + ctx: &ImSession, + _wk_name: &str, + channel_id: Uuid, + thread_id: Uuid, + message_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user; + let channel = self.resolve_channel(channel_id).await?; + self.ensure_channel_readable(user_uid, &channel).await?; + + let now = chrono::Utc::now(); + sqlx::query( + "INSERT INTO thread_read_state (id, user_id, thread_id, channel_id, last_read_message_id, last_read_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $6) \ + ON CONFLICT (user_id, thread_id) DO UPDATE SET \ + last_read_message_id = $5, last_read_at = $6, updated_at = $6", + ) + .bind(Uuid::now_v7()) + .bind(user_uid) + .bind(thread_id) + .bind(channel_id) + .bind(message_id) + .bind(now) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + Ok(()) + } + + pub(crate) async fn resolve_thread( + &self, + thread_id: Uuid, + channel_id: Uuid, + ) -> Result { + sqlx::query_as::<_, MessageThread>( + "SELECT id, channel_id, root_message_id, created_by, replies_count, \ + participants_count, last_reply_message_id, last_reply_at, resolved, \ + resolved_by, resolved_at, title, tags, pinned, locked, \ + rate_limit_per_user, auto_archive_at, created_at, updated_at \ + FROM message_thread WHERE id = $1 AND channel_id = $2", + ) + .bind(thread_id) + .bind(channel_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("thread not found".into())) + } +} diff --git a/service/im/util.rs b/service/im/util.rs new file mode 100644 index 0000000..40ead5a --- /dev/null +++ b/service/im/util.rs @@ -0,0 +1,64 @@ +pub use crate::service::util::{ + clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text, role_level, +}; + +/// Maximum length for a channel name. +pub const MAX_CHANNEL_NAME: usize = 100; + +/// Maximum length for a channel topic. +pub const MAX_CHANNEL_TOPIC: usize = 1024; + +/// Maximum length for a message body. +pub const MAX_MESSAGE_BODY: usize = 4096; + +/// Maximum length for an article title. +pub const MAX_ARTICLE_TITLE: usize = 256; + +/// Maximum number of poll options. +pub const MAX_POLL_OPTIONS: usize = 10; + +/// Maximum length for a poll option text. +pub const MAX_POLL_OPTION_TEXT: usize = 100; + +/// Redis key prefix for typing indicators. +pub const TYPING_PREFIX: &str = "im:typing:"; + +/// Redis key prefix for user presence. +pub const PRESENCE_PREFIX: &str = "im:presence:"; + +/// Redis TTL for typing indicators (seconds). +pub const TYPING_TTL_SECS: usize = 8; + +/// Redis TTL for presence heartbeats (seconds). +pub const PRESENCE_TTL_SECS: usize = 120; + +/// Maximum length for generated slugs. +pub const MAX_SLUG_LEN: usize = 128; + +/// Generate a slug from a title string. +pub fn slugify(title: &str) -> String { + let slug: String = title + .to_lowercase() + .chars() + .filter_map(|c| { + if c.is_ascii_alphanumeric() { + Some(c) + } else if c.is_whitespace() || !c.is_ascii() { + Some('-') + } else { + None + } + }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-"); + + let mut result = slug; + result.truncate(MAX_SLUG_LEN); + if result.ends_with('-') { + result.pop(); + } + result +} diff --git a/service/issues/assignees.rs b/service/issues/assignees.rs new file mode 100644 index 0000000..476be47 --- /dev/null +++ b/service/issues/assignees.rs @@ -0,0 +1,123 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::issues::IssueAssignee; +use crate::service::IssueService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected}; + +impl IssueService { + pub async fn issue_assignees( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_readable(user_uid, &issue).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, IssueAssignee>( + "SELECT id, issue_id, assignee_id, assigned_by, created_at \ + FROM issue_assignee WHERE issue_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(issue_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn issue_assign( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + assignee_id: Uuid, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_editable(user_uid, &issue).await?; + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + let assignee = sqlx::query_as::<_, IssueAssignee>( + "INSERT INTO issue_assignee (id, issue_id, assignee_id, assigned_by, created_at) \ + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (issue_id, assignee_id) DO NOTHING \ + RETURNING id, issue_id, assignee_id, assigned_by, created_at", + ) + .bind(Uuid::now_v7()) + .bind(issue_id) + .bind(assignee_id) + .bind(user_uid) + .bind(now) + .fetch_optional(&mut *txn) + .await + .map_err(AppError::Database)? + .ok_or(AppError::Conflict("user already assigned".into()))?; + sqlx::query("UPDATE issue_stats SET assignees_count = assignees_count + 1, updated_at = $1 WHERE issue_id = $2") + .bind(now).bind(issue_id).execute(&mut *txn).await.map_err(AppError::Database)?; + sqlx::query( + "INSERT INTO issue_subscriber (id, issue_id, user_id, reason, muted, created_at, updated_at) \ + VALUES ($1, $2, $3, 'assignee', false, $4, $4) ON CONFLICT DO NOTHING", + ) + .bind(Uuid::now_v7()).bind(issue_id).bind(assignee_id).bind(now) + .execute(&mut *txn).await.map_err(AppError::Database)?; + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(assignee) + } + + pub async fn issue_unassign( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + assignee_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_editable(user_uid, &issue).await?; + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + let result = + sqlx::query("DELETE FROM issue_assignee WHERE issue_id = $1 AND assignee_id = $2") + .bind(issue_id) + .bind(assignee_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "assignee not found")?; + sqlx::query("UPDATE issue_stats SET assignees_count = GREATEST(assignees_count - 1, 0), updated_at = $1 WHERE issue_id = $2") + .bind(now).bind(issue_id).execute(&mut *txn).await.map_err(AppError::Database)?; + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } +} diff --git a/service/issues/comments.rs b/service/issues/comments.rs new file mode 100644 index 0000000..bd8418b --- /dev/null +++ b/service/issues/comments.rs @@ -0,0 +1,230 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::issues::IssueComment; +use crate::service::IssueService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, required_text}; + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateCommentParams { + pub body: String, + pub reply_to_comment_id: Option, +} + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateCommentParams { + pub body: String, +} + +impl IssueService { + pub async fn issue_comments( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_readable(user_uid, &issue).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, IssueComment>( + "SELECT id, issue_id, author_id, body, reply_to_comment_id, edited_at, deleted_at, \ + created_at, updated_at FROM issue_comment \ + WHERE issue_id = $1 AND deleted_at IS NULL ORDER BY created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(issue_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn issue_create_comment( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + params: CreateCommentParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_readable(user_uid, &issue).await?; + + if issue.locked { + self.ensure_issue_editable(user_uid, &issue).await?; + } + + let body = required_text(params.body, "body")?; + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let comment = sqlx::query_as::<_, IssueComment>( + "INSERT INTO issue_comment (id, issue_id, author_id, body, reply_to_comment_id, \ + edited_at, deleted_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, NULL, NULL, $6, $6) \ + RETURNING id, issue_id, author_id, body, reply_to_comment_id, edited_at, deleted_at, \ + created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(issue_id) + .bind(user_uid) + .bind(&body) + .bind(params.reply_to_comment_id) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE issue_stats SET comments_count = comments_count + 1, \ + last_commented_at = $1, updated_at = $1 WHERE issue_id = $2", + ) + .bind(now) + .bind(issue_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let is_subscribed: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM issue_subscriber WHERE issue_id = $1 AND user_id = $2)", + ) + .bind(issue_id) + .bind(user_uid) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + if !is_subscribed { + sqlx::query( + "INSERT INTO issue_subscriber (id, issue_id, user_id, reason, muted, created_at, updated_at) \ + VALUES ($1, $2, $3, 'participant', false, $4, $4) ON CONFLICT DO NOTHING", + ) + .bind(Uuid::now_v7()).bind(issue_id).bind(user_uid).bind(now) + .execute(&mut *txn).await.map_err(AppError::Database)?; + } + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(comment) + } + + pub async fn issue_update_comment( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + comment_id: Uuid, + params: UpdateCommentParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_readable(user_uid, &issue).await?; + + let body = required_text(params.body, "body")?; + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, IssueComment>( + "UPDATE issue_comment SET body = $1, edited_at = $2, updated_at = $2 \ + WHERE id = $3 AND issue_id = $4 AND author_id = $5 AND deleted_at IS NULL \ + RETURNING id, issue_id, author_id, body, reply_to_comment_id, edited_at, deleted_at, \ + created_at, updated_at", + ) + .bind(&body) + .bind(now) + .bind(comment_id) + .bind(issue_id) + .bind(user_uid) + .fetch_optional(&mut *txn) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound( + "comment not found or not authored by you".into(), + ))?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn issue_delete_comment( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + comment_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + + let comment = sqlx::query_as::<_, IssueComment>( + "SELECT id, issue_id, author_id, body, reply_to_comment_id, edited_at, deleted_at, \ + created_at, updated_at FROM issue_comment WHERE id = $1 AND issue_id = $2 AND deleted_at IS NULL", + ) + .bind(comment_id).bind(issue_id).fetch_optional(self.ctx.db.reader()).await + .map_err(AppError::Database)?.ok_or(AppError::NotFound("comment not found".into()))?; + + if comment.author_id != user_uid { + self.ensure_issue_admin(user_uid, &issue).await?; + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE issue_comment SET deleted_at = $1, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL", + ) + .bind(now).bind(comment_id).execute(&mut *txn).await.map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "comment not found")?; + + sqlx::query( + "UPDATE issue_stats SET comments_count = GREATEST(comments_count - 1, 0), updated_at = $1 WHERE issue_id = $2", + ) + .bind(now).bind(issue_id).execute(&mut *txn).await.map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } +} diff --git a/service/issues/core.rs b/service/issues/core.rs new file mode 100644 index 0000000..de3185d --- /dev/null +++ b/service/issues/core.rs @@ -0,0 +1,867 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::{EventType, Priority, Role, State, Visibility}; +use crate::models::issues::Issue; +use crate::models::workspaces::Workspace; +use crate::service::IssueService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum}; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateIssueParams { + pub title: String, + pub body: Option, + pub priority: Option, + pub visibility: Option, + pub due_at: Option>, + pub repo_ids: Vec, + pub label_ids: Vec, + pub assignee_ids: Vec, + pub milestone_id: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateIssueParams { + pub title: Option, + pub body: Option, + pub priority: Option, + pub visibility: Option, + pub due_at: Option>, + pub milestone_id: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct IssueListFilters { + pub state: Option, + pub priority: Option, + pub author_id: Option, + pub assignee_id: Option, + pub milestone_id: Option, + pub label_id: Option, +} + +impl IssueService { + pub async fn issue_list( + &self, + ctx: &Session, + wk_name: &str, + filters: IssueListFilters, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.resolve_workspace(wk_name).await?; + self.ensure_workspace_readable(wk_name, user_uid).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + let state = filters + .state + .as_deref() + .and_then(|s| s.parse::().ok()) + .filter(|s| *s != State::Unknown); + let priority = filters + .priority + .as_deref() + .and_then(|s| s.parse::().ok()) + .filter(|p| *p != Priority::Unknown); + + sqlx::query_as::<_, Issue>( + "SELECT DISTINCT i.id, i.workspace_id, i.author_id, i.number, i.title, i.body, \ + i.state, i.priority, i.visibility, i.locked, i.milestone_id, i.closed_by, i.closed_at, i.due_at, \ + i.created_at, i.updated_at, i.deleted_at \ + FROM issue i \ + LEFT JOIN issue_assignee ia ON ia.issue_id = i.id \ + LEFT JOIN issue_label_relation ilr ON ilr.issue_id = i.id \ + WHERE i.workspace_id = $1 AND i.deleted_at IS NULL \ + AND ($2::text IS NULL OR i.state::text = $2) \ + AND ($3::text IS NULL OR i.priority::text = $3) \ + AND ($4::uuid IS NULL OR i.author_id = $4) \ + AND ($5::uuid IS NULL OR ia.assignee_id = $5) \ + AND ($6::uuid IS NULL OR i.milestone_id = $6) \ + AND ($7::uuid IS NULL OR ilr.label_id = $7) \ + AND (i.visibility = 'public' OR i.workspace_id IN \ + (SELECT workspace_id FROM workspace_member WHERE user_id = $8 AND status = 'active') \ + OR i.author_id = $8) \ + ORDER BY i.number DESC LIMIT $9 OFFSET $10", + ) + .bind(ws.id) + .bind(state.map(|s| s.to_string())) + .bind(priority.map(|p| p.to_string())) + .bind(filters.author_id) + .bind(filters.assignee_id) + .bind(filters.milestone_id) + .bind(filters.label_id) + .bind(user_uid) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn issue_get( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + self.ensure_issue_readable(user_uid, &issue).await?; + Ok(issue) + } + + #[tracing::instrument(skip(self, ctx, params), fields(title = %params.title))] + pub async fn issue_create( + &self, + ctx: &Session, + wk_name: &str, + params: CreateIssueParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let title = crate::service::util::required_text(params.title, "title")?; + let ws = self.resolve_workspace(wk_name).await?; + self.ensure_workspace_role_at_least(wk_name, user_uid, Role::Member) + .await?; + + // Validate repo_ids belong to this workspace + for repo_id in ¶ms.repo_ids { + let repo = crate::models::repos::Repo::find_by_id(self.ctx.db.reader(), *repo_id) + .await + .map_err(AppError::Database)? + .ok_or_else(|| AppError::NotFound(format!("Repository {} not found", repo_id)))?; + + if repo.workspace_id != ws.id { + return Err(AppError::BadRequest(format!( + "Repository {} does not belong to this workspace", + repo_id + ))); + } + + self.ensure_repo_readable(user_uid, &repo).await?; + } + + // Validate label_ids belong to repos in this workspace + for label_id in ¶ms.label_ids { + let label: Option<(Uuid,)> = sqlx::query_as( + "SELECT r.workspace_id FROM issue_label il \ + JOIN repo r ON r.id = il.repo_id \ + WHERE il.id = $1", + ) + .bind(label_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + match label { + Some((workspace_id,)) if workspace_id == ws.id => {} + Some(_) => { + return Err(AppError::BadRequest(format!( + "Label {} does not belong to this workspace", + label_id + ))); + } + None => return Err(AppError::NotFound(format!("Label {} not found", label_id))), + } + } + + // Validate assignee_ids are workspace members + for assignee_id in ¶ms.assignee_ids { + let is_member: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM workspace_member \ + WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')", + ) + .bind(ws.id) + .bind(assignee_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if !is_member { + return Err(AppError::BadRequest(format!( + "User {} is not a member of this workspace", + assignee_id + ))); + } + } + + // Validate milestone_id belongs to a repo in this workspace + if let Some(milestone_id) = params.milestone_id { + let milestone: Option<(Uuid,)> = sqlx::query_as( + "SELECT r.workspace_id FROM issue_milestone im \ + JOIN repo r ON r.id = im.repo_id \ + WHERE im.id = $1", + ) + .bind(milestone_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + match milestone { + Some((workspace_id,)) if workspace_id == ws.id => {} + Some(_) => { + return Err(AppError::BadRequest( + "Milestone does not belong to this workspace".into(), + )); + } + None => return Err(AppError::NotFound("Milestone not found".into())), + } + } + + let priority = match params.priority { + Some(ref v) => parse_enum( + Some(v.clone()), + Priority::None, + Priority::Unknown, + "priority", + )?, + None => Priority::None, + }; + let visibility = match params.visibility { + Some(ref v) => parse_enum( + Some(v.clone()), + Visibility::Public, + Visibility::Unknown, + "visibility", + )?, + None => Visibility::Public, + }; + + let now = chrono::Utc::now(); + let issue_id = Uuid::now_v7(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let number = Issue::next_number(&mut *txn, ws.id) + .await + .map_err(AppError::Database)?; + + let issue = sqlx::query_as::<_, Issue>( + "INSERT INTO issue (id, workspace_id, author_id, number, title, body, state, priority, \ + visibility, locked, milestone_id, closed_by, closed_at, due_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, 'open', $7, $8, false, $9, NULL, NULL, $10, $11, $11) \ + RETURNING id, workspace_id, author_id, number, title, body, state, priority, \ + visibility, locked, milestone_id, closed_by, closed_at, due_at, created_at, updated_at, deleted_at", + ) + .bind(issue_id) + .bind(ws.id) + .bind(user_uid) + .bind(number) + .bind(&title) + .bind(params.body.as_deref()) + .bind(priority) + .bind(visibility) + .bind(params.milestone_id) + .bind(params.due_at) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO issue_stats (issue_id, comments_count, reactions_count, assignees_count, \ + labels_count, subscribers_count, last_commented_at, updated_at) \ + VALUES ($1, 0, 0, $2, $3, 1, NULL, $4)", + ) + .bind(issue_id) + .bind(params.assignee_ids.len() as i32) + .bind(params.label_ids.len() as i32) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO issue_subscriber (id, issue_id, user_id, reason, muted, created_at, updated_at) \ + VALUES ($1, $2, $3, 'author', false, $4, $4)", + ) + .bind(Uuid::now_v7()) + .bind(issue_id) + .bind(user_uid) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + for repo_id in ¶ms.repo_ids { + sqlx::query( + "INSERT INTO issue_repo_relation (id, issue_id, repo_id, relation_type, created_by, created_at) \ + VALUES ($1, $2, $3, 'references', $4, $5)", + ) + .bind(Uuid::now_v7()) + .bind(issue_id) + .bind(repo_id) + .bind(user_uid) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } + + for label_id in ¶ms.label_ids { + sqlx::query( + "INSERT INTO issue_label_relation (id, issue_id, label_id, created_by, created_at) \ + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(Uuid::now_v7()) + .bind(issue_id) + .bind(label_id) + .bind(user_uid) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } + + for assignee_id in ¶ms.assignee_ids { + sqlx::query( + "INSERT INTO issue_assignee (id, issue_id, assignee_id, assigned_by, created_at) \ + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(Uuid::now_v7()) + .bind(issue_id) + .bind(assignee_id) + .bind(user_uid) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } + + sqlx::query( + "UPDATE workspace_stats SET issues_count = issues_count + 1, updated_at = $1 \ + WHERE workspace_id = $2", + ) + .bind(now) + .bind(ws.id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + + self.record_issue_event(issue_id, Some(user_uid), EventType::Created) + .await; + tracing::info!(issue_id = %issue_id, number = number, "Issue created"); + Ok(issue) + } + + pub async fn issue_update( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + params: UpdateIssueParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_editable(user_uid, &issue).await?; + + let title = merge_optional_text(params.title, Some(issue.title.clone())) + .unwrap_or(issue.title.clone()); + let body = merge_optional_text(params.body, issue.body.clone()); + let priority = match params.priority { + Some(ref v) => parse_enum( + Some(v.clone()), + issue.priority, + Priority::Unknown, + "priority", + )?, + None => issue.priority, + }; + let visibility = match params.visibility { + Some(ref v) => parse_enum( + Some(v.clone()), + issue.visibility, + Visibility::Unknown, + "visibility", + )?, + None => issue.visibility, + }; + + if let Some(milestone_id) = params.milestone_id { + self.ensure_milestone_in_workspace(milestone_id, issue.workspace_id) + .await?; + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, Issue>( + "UPDATE issue SET title = $1, body = $2, priority = $3, visibility = $4, \ + due_at = $5, milestone_id = $6, updated_at = $7 WHERE id = $8 AND deleted_at IS NULL \ + RETURNING id, workspace_id, author_id, number, title, body, state, priority, \ + visibility, locked, milestone_id, closed_by, closed_at, due_at, created_at, updated_at, deleted_at", + ) + .bind(&title) + .bind(&body) + .bind(priority) + .bind(visibility) + .bind(params.due_at.or(issue.due_at)) + .bind(params.milestone_id.or(issue.milestone_id)) + .bind(now) + .bind(issue_id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + self.record_issue_event(issue_id, Some(user_uid), EventType::Updated) + .await; + Ok(result) + } + + pub async fn issue_close( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_editable(user_uid, &issue).await?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, Issue>( + "UPDATE issue SET state = 'closed', closed_by = $1, closed_at = $2, updated_at = $2 \ + WHERE id = $3 AND deleted_at IS NULL AND state = 'open' \ + RETURNING id, workspace_id, author_id, number, title, body, state, priority, \ + visibility, locked, milestone_id, closed_by, closed_at, due_at, created_at, updated_at, deleted_at", + ) + .bind(user_uid) + .bind(now) + .bind(issue_id) + .fetch_optional(&mut *txn) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("issue not found or already closed".into()))?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + self.record_issue_event(issue_id, Some(user_uid), EventType::Closed) + .await; + Ok(result) + } + + pub async fn issue_reopen( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_editable(user_uid, &issue).await?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, Issue>( + "UPDATE issue SET state = 'open', closed_by = NULL, closed_at = NULL, updated_at = $1 \ + WHERE id = $2 AND deleted_at IS NULL AND state = 'closed' \ + RETURNING id, workspace_id, author_id, number, title, body, state, priority, \ + visibility, locked, milestone_id, closed_by, closed_at, due_at, created_at, updated_at, deleted_at", + ) + .bind(now) + .bind(issue_id) + .fetch_optional(&mut *txn) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("issue not found or not closed".into()))?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + self.record_issue_event(issue_id, Some(user_uid), EventType::Reopened) + .await; + Ok(result) + } + + pub async fn issue_delete( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_admin(user_uid, &issue).await?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE issue SET deleted_at = $1, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL", + ) + .bind(now) + .bind(issue_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "issue not found")?; + + sqlx::query( + "UPDATE workspace_stats SET issues_count = GREATEST(issues_count - 1, 0), updated_at = $1 \ + WHERE workspace_id = $2", + ) + .bind(now) + .bind(issue.workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + self.record_issue_event(issue_id, Some(user_uid), EventType::Deleted) + .await; + Ok(()) + } + + pub async fn issue_lock( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + locked: bool, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_editable(user_uid, &issue).await?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, Issue>( + "UPDATE issue SET locked = $1, updated_at = $2 WHERE id = $3 AND deleted_at IS NULL \ + RETURNING id, workspace_id, author_id, number, title, body, state, priority, \ + visibility, locked, milestone_id, closed_by, closed_at, due_at, created_at, updated_at, deleted_at", + ) + .bind(locked) + .bind(now) + .bind(issue_id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + let event_type = if locked { + EventType::Archived + } else { + EventType::Restored + }; + self.record_issue_event(issue_id, Some(user_uid), event_type) + .await; + Ok(result) + } + + pub async fn issue_transfer( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + new_wk_name: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_admin(user_uid, &issue).await?; + let new_ws = self.resolve_workspace(new_wk_name).await?; + self.ensure_workspace_role_at_least(new_wk_name, user_uid, Role::Admin) + .await?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let new_number = Issue::next_number(&mut *txn, new_ws.id) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, Issue>( + "UPDATE issue SET workspace_id = $1, number = $2, updated_at = $3 WHERE id = $4 AND deleted_at IS NULL \ + RETURNING id, workspace_id, author_id, number, title, body, state, priority, \ + visibility, locked, milestone_id, closed_by, closed_at, due_at, created_at, updated_at, deleted_at", + ) + .bind(new_ws.id) + .bind(new_number) + .bind(now) + .bind(issue_id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE workspace_stats SET issues_count = GREATEST(issues_count - 1, 0), updated_at = $1 WHERE workspace_id = $2", + ).bind(now).bind(issue.workspace_id).execute(&mut *txn).await.map_err(AppError::Database)?; + + sqlx::query( + "UPDATE workspace_stats SET issues_count = issues_count + 1, updated_at = $1 WHERE workspace_id = $2", + ).bind(now).bind(new_ws.id).execute(&mut *txn).await.map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + self.record_issue_event(issue_id, Some(user_uid), EventType::Updated) + .await; + Ok(result) + } + + async fn record_issue_event( + &self, + issue_id: Uuid, + actor_id: Option, + event_type: EventType, + ) { + if let Err(err) = self + .create_issue_event(issue_id, actor_id, event_type, None, None, None) + .await + { + tracing::warn!(issue_id = %issue_id, error = %err, "Failed to create issue event"); + } + } + + async fn ensure_milestone_in_workspace( + &self, + milestone_id: Uuid, + workspace_id: Uuid, + ) -> Result<(), AppError> { + let milestone_workspace_id: Option = sqlx::query_scalar( + "SELECT r.workspace_id FROM issue_milestone im \ + JOIN repo r ON r.id = im.repo_id \ + WHERE im.id = $1", + ) + .bind(milestone_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + match milestone_workspace_id { + Some(id) if id == workspace_id => Ok(()), + Some(_) => Err(AppError::BadRequest( + "Milestone does not belong to this workspace".into(), + )), + None => Err(AppError::NotFound("Milestone not found".into())), + } + } + + pub(crate) async fn resolve_workspace(&self, wk_name: &str) -> Result { + Workspace::find_by_name(self.ctx.db.reader(), wk_name) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("workspace not found".into())) + } + + pub(crate) async fn resolve_issue( + &self, + wk_name: &str, + number: i64, + ) -> Result { + let ws = self.resolve_workspace(wk_name).await?; + Issue::find_by_number(self.ctx.db.reader(), ws.id, number) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("issue not found".into())) + } + + #[allow(dead_code)] + pub(crate) async fn find_issue_by_id(&self, issue_id: Uuid) -> Result { + Issue::find_by_id(self.ctx.db.reader(), issue_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("issue not found".into())) + } + + pub(crate) async fn ensure_issue_readable( + &self, + user_uid: Uuid, + issue: &Issue, + ) -> Result<(), AppError> { + if issue.author_id == user_uid { + return Ok(()); + } + let is_member = Workspace::is_member(self.ctx.db.reader(), issue.workspace_id, user_uid) + .await + .map_err(AppError::Database)?; + if is_member { + return Ok(()); + } + if issue.visibility == Visibility::Public { + return Ok(()); + } + Err(AppError::Unauthorized) + } + + pub(crate) async fn ensure_issue_editable( + &self, + user_uid: Uuid, + issue: &Issue, + ) -> Result<(), AppError> { + if issue.author_id == user_uid { + return Ok(()); + } + let role = Workspace::user_role( + self.ctx.db.reader(), + issue.workspace_id, + user_uid, + Uuid::nil(), + ) + .await + .map_err(AppError::Database)? + .unwrap_or(Role::Unknown); + if crate::service::util::role_level(role) >= crate::service::util::role_level(Role::Member) + { + return Ok(()); + } + Err(AppError::Unauthorized) + } + + pub(crate) async fn ensure_issue_admin( + &self, + user_uid: Uuid, + issue: &Issue, + ) -> Result<(), AppError> { + let role = Workspace::user_role( + self.ctx.db.reader(), + issue.workspace_id, + user_uid, + Uuid::nil(), + ) + .await + .map_err(AppError::Database)? + .unwrap_or(Role::Unknown); + if crate::service::util::role_level(role) >= crate::service::util::role_level(Role::Admin) { + return Ok(()); + } + Err(AppError::Unauthorized) + } + + pub(crate) async fn ensure_workspace_readable( + &self, + wk_name: &str, + user_uid: Uuid, + ) -> Result<(), AppError> { + let ws = self.resolve_workspace(wk_name).await?; + if Workspace::is_readable(self.ctx.db.reader(), &ws, user_uid) + .await + .map_err(AppError::Database)? + { + Ok(()) + } else { + Err(AppError::Unauthorized) + } + } + + pub(crate) async fn ensure_workspace_role_at_least( + &self, + wk_name: &str, + user_uid: Uuid, + min_role: Role, + ) -> Result { + let ws = self.resolve_workspace(wk_name).await?; + let role = Workspace::user_role(self.ctx.db.reader(), ws.id, user_uid, ws.owner_id) + .await + .map_err(AppError::Database)? + .unwrap_or(Role::Unknown); + if crate::service::util::role_level(role) < crate::service::util::role_level(min_role) { + return Err(AppError::Unauthorized); + } + Ok(role) + } + + pub(crate) async fn ensure_repo_readable( + &self, + user_uid: Uuid, + repo: &crate::models::repos::Repo, + ) -> Result<(), AppError> { + use crate::models::repos::Repo; + + if Repo::is_readable(self.ctx.db.reader(), repo, user_uid) + .await + .map_err(AppError::Database)? + { + Ok(()) + } else { + Err(AppError::Unauthorized) + } + } +} diff --git a/service/issues/events.rs b/service/issues/events.rs new file mode 100644 index 0000000..d5a7885 --- /dev/null +++ b/service/issues/events.rs @@ -0,0 +1,63 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::{EventType, JsonValue}; +use crate::models::issues::IssueEvent; +use crate::service::IssueService; +use crate::session::Session; + +use super::util::clamp_limit_offset; + +impl IssueService { + pub async fn issue_list_events( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + self.ensure_issue_readable(user_uid, &issue).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + sqlx::query_as::<_, IssueEvent>( + "SELECT id, issue_id, actor_id, event_type, old_value, new_value, metadata, created_at \ + FROM issue_event WHERE issue_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(issue.id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub(crate) async fn create_issue_event( + &self, + issue_id: Uuid, + actor_id: Option, + event_type: EventType, + old_value: Option, + new_value: Option, + metadata: Option, + ) -> Result { + sqlx::query_as::<_, IssueEvent>( + "INSERT INTO issue_event (id, issue_id, actor_id, event_type, old_value, new_value, metadata, created_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \ + RETURNING id, issue_id, actor_id, event_type, old_value, new_value, metadata, created_at", + ) + .bind(Uuid::now_v7()) + .bind(issue_id) + .bind(actor_id) + .bind(event_type) + .bind(old_value) + .bind(new_value) + .bind(metadata) + .bind(chrono::Utc::now()) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database) + } +} diff --git a/service/issues/labels.rs b/service/issues/labels.rs new file mode 100644 index 0000000..c2fde77 --- /dev/null +++ b/service/issues/labels.rs @@ -0,0 +1,268 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::issues::{IssueLabel, IssueLabelRelation}; +use crate::service::IssueService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, required_text}; + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateLabelParams { + pub name: String, + pub color: String, + pub description: Option, +} + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateLabelParams { + pub name: Option, + pub color: Option, + pub description: Option, +} + +impl IssueService { + pub(crate) async fn resolve_repo_id( + &self, + wk_name: &str, + repo_name: &str, + ) -> Result { + let ws = self.resolve_workspace(wk_name).await?; + crate::models::repos::Repo::find_by_name(self.ctx.db.reader(), ws.id, repo_name) + .await + .map_err(AppError::Database)? + .map(|r| r.id) + .ok_or(AppError::NotFound("repo not found".into())) + } + + pub(crate) async fn ensure_repo_role( + &self, + repo_id: Uuid, + user_uid: Uuid, + min: Role, + ) -> Result<(), AppError> { + let repo = crate::models::repos::Repo::find_by_id(self.ctx.db.reader(), repo_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("repo not found".into()))?; + let role = crate::models::repos::Repo::user_role( + self.ctx.db.reader(), + repo.id, + user_uid, + repo.owner_id, + ) + .await + .map_err(AppError::Database)? + .unwrap_or(Role::Unknown); + if crate::service::util::role_level(role) < crate::service::util::role_level(min) { + return Err(AppError::Unauthorized); + } + Ok(()) + } + + pub async fn issue_labels( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo_id = self.resolve_repo_id(wk_name, repo_name).await?; + let repo = crate::models::repos::Repo::find_by_id(self.ctx.db.reader(), repo_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("repo not found".into()))?; + self.ensure_repo_readable(user_uid, &repo).await?; + sqlx::query_as::<_, IssueLabel>( + "SELECT id, repo_id, name, color, description, created_by, created_at, updated_at \ + FROM issue_label WHERE repo_id = $1 ORDER BY name ASC", + ) + .bind(repo_id) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn issue_create_label( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: CreateLabelParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo_id = self.resolve_repo_id(wk_name, repo_name).await?; + self.ensure_repo_role(repo_id, user_uid, Role::Member) + .await?; + let name = required_text(params.name, "name")?; + let color = required_text(params.color, "color")?; + let now = chrono::Utc::now(); + sqlx::query_as::<_, IssueLabel>( + "INSERT INTO issue_label (id, repo_id, name, color, description, created_by, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $7) \ + RETURNING id, repo_id, name, color, description, created_by, created_at, updated_at", + ) + .bind(Uuid::now_v7()).bind(repo_id).bind(&name).bind(&color) + .bind(params.description.as_deref()).bind(user_uid).bind(now) + .fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database) + } + + pub async fn issue_update_label( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + label_id: Uuid, + params: UpdateLabelParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo_id = self.resolve_repo_id(wk_name, repo_name).await?; + self.ensure_repo_role(repo_id, user_uid, Role::Admin) + .await?; + let current = sqlx::query_as::<_, IssueLabel>( + "SELECT id, repo_id, name, color, description, created_by, created_at, updated_at \ + FROM issue_label WHERE id = $1 AND repo_id = $2", + ) + .bind(label_id) + .bind(repo_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("label not found".into()))?; + + let name = params.name.unwrap_or(current.name); + let color = params.color.unwrap_or(current.color); + let description = super::util::merge_optional_text(params.description, current.description); + let now = chrono::Utc::now(); + sqlx::query_as::<_, IssueLabel>( + "UPDATE issue_label SET name = $1, color = $2, description = $3, updated_at = $4 \ + WHERE id = $5 RETURNING id, repo_id, name, color, description, created_by, created_at, updated_at", + ) + .bind(&name).bind(&color).bind(&description).bind(now).bind(label_id) + .fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database) + } + + pub async fn issue_delete_label( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + label_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo_id = self.resolve_repo_id(wk_name, repo_name).await?; + self.ensure_repo_role(repo_id, user_uid, Role::Admin) + .await?; + let result = sqlx::query("DELETE FROM issue_label WHERE id = $1 AND repo_id = $2") + .bind(label_id) + .bind(repo_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "label not found") + } + + pub async fn issue_assign_label( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + label_id: Uuid, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_editable(user_uid, &issue).await?; + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + let rel = sqlx::query_as::<_, IssueLabelRelation>( + "INSERT INTO issue_label_relation (id, issue_id, label_id, created_by, created_at) \ + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (issue_id, label_id) DO NOTHING \ + RETURNING id, issue_id, label_id, created_by, created_at", + ) + .bind(Uuid::now_v7()) + .bind(issue_id) + .bind(label_id) + .bind(user_uid) + .bind(now) + .fetch_optional(&mut *txn) + .await + .map_err(AppError::Database)? + .ok_or(AppError::Conflict("label already assigned".into()))?; + sqlx::query("UPDATE issue_stats SET labels_count = labels_count + 1, updated_at = $1 WHERE issue_id = $2") + .bind(now).bind(issue_id).execute(&mut *txn).await.map_err(AppError::Database)?; + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(rel) + } + + pub async fn issue_unassign_label( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + label_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_editable(user_uid, &issue).await?; + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + let result = + sqlx::query("DELETE FROM issue_label_relation WHERE issue_id = $1 AND label_id = $2") + .bind(issue_id) + .bind(label_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "label not assigned")?; + sqlx::query("UPDATE issue_stats SET labels_count = GREATEST(labels_count - 1, 0), updated_at = $1 WHERE issue_id = $2") + .bind(now).bind(issue_id).execute(&mut *txn).await.map_err(AppError::Database)?; + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn issue_label_relations( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_readable(user_uid, &issue).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, IssueLabelRelation>( + "SELECT id, issue_id, label_id, created_by, created_at \ + FROM issue_label_relation WHERE issue_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(issue_id).bind(limit).bind(offset) + .fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database) + } +} diff --git a/service/issues/milestones.rs b/service/issues/milestones.rs new file mode 100644 index 0000000..b4dc553 --- /dev/null +++ b/service/issues/milestones.rs @@ -0,0 +1,137 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::{Role, State}; +use crate::models::issues::IssueMilestone; +use crate::service::IssueService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, merge_optional_text, required_text}; + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateMilestoneParams { + pub title: String, + pub description: Option, + pub due_at: Option>, +} + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateMilestoneParams { + pub title: Option, + pub description: Option, + pub due_at: Option>, + pub state: Option, +} + +impl IssueService { + pub async fn issue_milestones( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo_id = self.resolve_repo_id(wk_name, repo_name).await?; + let repo = crate::models::repos::Repo::find_by_id(self.ctx.db.reader(), repo_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("repo not found".into()))?; + self.ensure_repo_readable(user_uid, &repo).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, IssueMilestone>( + "SELECT id, repo_id, title, description, state, due_at, closed_at, created_by, \ + created_at, updated_at FROM issue_milestone WHERE repo_id = $1 \ + ORDER BY state ASC, due_at ASC NULLS LAST LIMIT $2 OFFSET $3", + ) + .bind(repo_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn issue_create_milestone( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: CreateMilestoneParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo_id = self.resolve_repo_id(wk_name, repo_name).await?; + self.ensure_repo_role(repo_id, user_uid, Role::Member) + .await?; + let title = required_text(params.title, "title")?; + let now = chrono::Utc::now(); + sqlx::query_as::<_, IssueMilestone>( + "INSERT INTO issue_milestone (id, repo_id, title, description, state, due_at, closed_at, \ + created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, 'open', $5, NULL, $6, $7, $7) \ + RETURNING id, repo_id, title, description, state, due_at, closed_at, created_by, created_at, updated_at", + ) + .bind(Uuid::now_v7()).bind(repo_id).bind(&title).bind(params.description.as_deref()) + .bind(params.due_at).bind(user_uid).bind(now) + .fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database) + } + + pub async fn issue_update_milestone( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + milestone_id: Uuid, + params: UpdateMilestoneParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo_id = self.resolve_repo_id(wk_name, repo_name).await?; + self.ensure_repo_role(repo_id, user_uid, Role::Member) + .await?; + let current = sqlx::query_as::<_, IssueMilestone>( + "SELECT id, repo_id, title, description, state, due_at, closed_at, created_by, created_at, updated_at \ + FROM issue_milestone WHERE id = $1 AND repo_id = $2", + ) + .bind(milestone_id).bind(repo_id).fetch_optional(self.ctx.db.reader()).await + .map_err(AppError::Database)?.ok_or(AppError::NotFound("milestone not found".into()))?; + + let title = params.title.unwrap_or(current.title); + let description = merge_optional_text(params.description, current.description); + let due_at = params.due_at.or(current.due_at); + let now = chrono::Utc::now(); + + let (state, closed_at) = match params.state.as_deref() { + Some("closed") if current.state != State::Closed => (State::Closed, Some(now)), + Some("open") if current.state != State::Open => (State::Open, None), + _ => (current.state, current.closed_at), + }; + + sqlx::query_as::<_, IssueMilestone>( + "UPDATE issue_milestone SET title = $1, description = $2, state = $3, due_at = $4, \ + closed_at = $5, updated_at = $6 WHERE id = $7 \ + RETURNING id, repo_id, title, description, state, due_at, closed_at, created_by, created_at, updated_at", + ) + .bind(&title).bind(&description).bind(state).bind(due_at).bind(closed_at).bind(now).bind(milestone_id) + .fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database) + } + + pub async fn issue_delete_milestone( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + milestone_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo_id = self.resolve_repo_id(wk_name, repo_name).await?; + self.ensure_repo_role(repo_id, user_uid, Role::Admin) + .await?; + let result = sqlx::query("DELETE FROM issue_milestone WHERE id = $1 AND repo_id = $2") + .bind(milestone_id) + .bind(repo_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "milestone not found") + } +} diff --git a/service/issues/mod.rs b/service/issues/mod.rs new file mode 100644 index 0000000..9dc79c8 --- /dev/null +++ b/service/issues/mod.rs @@ -0,0 +1,12 @@ +pub mod assignees; +pub mod comments; +pub mod core; +pub mod events; +pub mod labels; +pub mod milestones; +pub mod pr_relations; +pub mod reactions; +pub mod repo_relations; +pub mod subscribers; +pub mod templates; +pub mod util; diff --git a/service/issues/pr_relations.rs b/service/issues/pr_relations.rs new file mode 100644 index 0000000..81a4796 --- /dev/null +++ b/service/issues/pr_relations.rs @@ -0,0 +1,122 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::RelationType; +use crate::models::issues::IssuePrRelation; +use crate::service::IssueService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, parse_enum}; + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct LinkPrParams { + pub pull_request_id: Uuid, + pub relation_type: Option, +} + +impl IssueService { + pub async fn issue_pr_relations( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_readable(user_uid, &issue).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, IssuePrRelation>( + "SELECT id, issue_id, pull_request_id, relation_type, created_by, created_at \ + FROM issue_pr_relation WHERE issue_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(issue_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn issue_link_pr( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + params: LinkPrParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_editable(user_uid, &issue).await?; + let relation_type = match params.relation_type { + Some(ref v) => parse_enum( + Some(v.clone()), + RelationType::References, + RelationType::Unknown, + "relation_type", + )?, + None => RelationType::References, + }; + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + let rel = sqlx::query_as::<_, IssuePrRelation>( + "INSERT INTO issue_pr_relation (id, issue_id, pull_request_id, relation_type, created_by, created_at) \ + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (issue_id, pull_request_id) DO NOTHING \ + RETURNING id, issue_id, pull_request_id, relation_type, created_by, created_at", + ) + .bind(Uuid::now_v7()).bind(issue_id).bind(params.pull_request_id) + .bind(relation_type).bind(user_uid).bind(now) + .fetch_optional(&mut *txn).await.map_err(AppError::Database)? + .ok_or(AppError::Conflict("PR already linked".into()))?; + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(rel) + } + + pub async fn issue_unlink_pr( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + relation_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_editable(user_uid, &issue).await?; + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + let result = sqlx::query("DELETE FROM issue_pr_relation WHERE id = $1 AND issue_id = $2") + .bind(relation_id) + .bind(issue_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "PR relation not found")?; + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } +} diff --git a/service/issues/reactions.rs b/service/issues/reactions.rs new file mode 100644 index 0000000..c4ffea4 --- /dev/null +++ b/service/issues/reactions.rs @@ -0,0 +1,108 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::TargetType; +use crate::models::issues::IssueReaction; +use crate::service::IssueService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, required_text}; + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateIssueReactionParams { + pub content: String, + pub target_type: Option, + pub target_id: Option, +} + +impl IssueService { + pub async fn issue_reactions( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + self.ensure_issue_readable(user_uid, &issue).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + sqlx::query_as::<_, IssueReaction>( + "SELECT id, issue_id, user_id, content, target_type, target_id, created_at \ + FROM issue_reaction WHERE issue_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(issue.id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn issue_add_reaction( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + params: CreateIssueReactionParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + self.ensure_issue_readable(user_uid, &issue).await?; + + let content = required_text(params.content, "content")?; + let target_type = params + .target_type + .as_deref() + .and_then(|s| s.parse::().ok()) + .unwrap_or(TargetType::Issue); + if target_type == TargetType::Unknown { + return Err(AppError::BadRequest("invalid target_type".into())); + } + + let now = chrono::Utc::now(); + sqlx::query_as::<_, IssueReaction>( + "INSERT INTO issue_reaction (id, issue_id, user_id, content, target_type, target_id, created_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7) \ + RETURNING id, issue_id, user_id, content, target_type, target_id, created_at", + ) + .bind(Uuid::now_v7()) + .bind(issue.id) + .bind(user_uid) + .bind(&content) + .bind(target_type) + .bind(params.target_id) + .bind(now) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database) + } + + pub async fn issue_remove_reaction( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + reaction_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + self.ensure_issue_readable(user_uid, &issue).await?; + + let result = sqlx::query( + "DELETE FROM issue_reaction WHERE id = $1 AND issue_id = $2 AND user_id = $3", + ) + .bind(reaction_id) + .bind(issue.id) + .bind(user_uid) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + ensure_affected( + result.rows_affected(), + "reaction not found or not authored by you", + ) + } +} diff --git a/service/issues/repo_relations.rs b/service/issues/repo_relations.rs new file mode 100644 index 0000000..549b91c --- /dev/null +++ b/service/issues/repo_relations.rs @@ -0,0 +1,118 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::RelationType; +use crate::models::issues::IssueRepoRelation; +use crate::service::IssueService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, parse_enum}; + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct LinkRepoParams { + pub repo_id: Uuid, + pub relation_type: Option, +} + +impl IssueService { + pub async fn issue_repo_relations( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_readable(user_uid, &issue).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, IssueRepoRelation>( + "SELECT id, issue_id, repo_id, relation_type, created_by, created_at \ + FROM issue_repo_relation WHERE issue_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(issue_id).bind(limit).bind(offset) + .fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database) + } + + pub async fn issue_link_repo( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + params: LinkRepoParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_editable(user_uid, &issue).await?; + let relation_type = match params.relation_type { + Some(ref v) => parse_enum( + Some(v.clone()), + RelationType::References, + RelationType::Unknown, + "relation_type", + )?, + None => RelationType::References, + }; + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + let rel = sqlx::query_as::<_, IssueRepoRelation>( + "INSERT INTO issue_repo_relation (id, issue_id, repo_id, relation_type, created_by, created_at) \ + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (issue_id, repo_id) DO NOTHING \ + RETURNING id, issue_id, repo_id, relation_type, created_by, created_at", + ) + .bind(Uuid::now_v7()).bind(issue_id).bind(params.repo_id) + .bind(relation_type).bind(user_uid).bind(now) + .fetch_optional(&mut *txn).await.map_err(AppError::Database)? + .ok_or(AppError::Conflict("repo already linked".into()))?; + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(rel) + } + + pub async fn issue_unlink_repo( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + relation_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_editable(user_uid, &issue).await?; + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + let result = sqlx::query("DELETE FROM issue_repo_relation WHERE id = $1 AND issue_id = $2") + .bind(relation_id) + .bind(issue_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "repo relation not found")?; + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } +} diff --git a/service/issues/subscribers.rs b/service/issues/subscribers.rs new file mode 100644 index 0000000..d23fcbb --- /dev/null +++ b/service/issues/subscribers.rs @@ -0,0 +1,126 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::issues::IssueSubscriber; +use crate::service::IssueService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected}; + +impl IssueService { + pub async fn issue_subscribers( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_readable(user_uid, &issue).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, IssueSubscriber>( + "SELECT id, issue_id, user_id, reason, muted, created_at, updated_at \ + FROM issue_subscriber WHERE issue_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(issue_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn issue_subscribe( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + self.ensure_issue_readable(user_uid, &issue).await?; + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + let sub = sqlx::query_as::<_, IssueSubscriber>( + "INSERT INTO issue_subscriber (id, issue_id, user_id, reason, muted, created_at, updated_at) \ + VALUES ($1, $2, $3, 'manual', false, $4, $4) ON CONFLICT (issue_id, user_id) DO NOTHING \ + RETURNING id, issue_id, user_id, reason, muted, created_at, updated_at", + ) + .bind(Uuid::now_v7()).bind(issue_id).bind(user_uid).bind(now) + .fetch_optional(&mut *txn).await.map_err(AppError::Database)? + .ok_or(AppError::Conflict("already subscribed".into()))?; + sqlx::query("UPDATE issue_stats SET subscribers_count = subscribers_count + 1, updated_at = $1 WHERE issue_id = $2") + .bind(now).bind(issue_id).execute(&mut *txn).await.map_err(AppError::Database)?; + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(sub) + } + + pub async fn issue_unsubscribe( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + let result = + sqlx::query("DELETE FROM issue_subscriber WHERE issue_id = $1 AND user_id = $2") + .bind(issue_id) + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "not subscribed")?; + sqlx::query("UPDATE issue_stats SET subscribers_count = GREATEST(subscribers_count - 1, 0), updated_at = $1 WHERE issue_id = $2") + .bind(now).bind(issue_id).execute(&mut *txn).await.map_err(AppError::Database)?; + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn issue_mute( + &self, + ctx: &Session, + wk_name: &str, + number: i64, + muted: bool, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let issue = self.resolve_issue(wk_name, number).await?; + let issue_id = issue.id; + let result = sqlx::query( + "UPDATE issue_subscriber SET muted = $1, updated_at = $2 WHERE issue_id = $3 AND user_id = $4", + ) + .bind(muted).bind(chrono::Utc::now()).bind(issue_id).bind(user_uid) + .execute(self.ctx.db.writer()).await.map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "not subscribed") + } +} diff --git a/service/issues/templates.rs b/service/issues/templates.rs new file mode 100644 index 0000000..54c3c49 --- /dev/null +++ b/service/issues/templates.rs @@ -0,0 +1,158 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::issues::IssueTemplate; +use crate::service::IssueService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, required_text}; + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateTemplateParams { + pub name: String, + pub description: Option, + pub title_template: Option, + pub body_template: String, + pub labels: Vec, +} + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateTemplateParams { + pub name: Option, + pub description: Option, + pub title_template: Option, + pub body_template: Option, + pub labels: Option>, + pub active: Option, +} + +impl IssueService { + pub async fn issue_templates( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo_id = self.resolve_repo_id(wk_name, repo_name).await?; + let repo = crate::models::repos::Repo::find_by_id(self.ctx.db.reader(), repo_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("repo not found".into()))?; + self.ensure_repo_readable(user_uid, &repo).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, IssueTemplate>( + "SELECT id, repo_id, name, description, title_template, body_template, labels, \ + active, created_by, created_at, updated_at \ + FROM issue_template WHERE repo_id = $1 AND active = true \ + ORDER BY name ASC LIMIT $2 OFFSET $3", + ) + .bind(repo_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn issue_create_template( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: CreateTemplateParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo_id = self.resolve_repo_id(wk_name, repo_name).await?; + self.ensure_repo_role(repo_id, user_uid, Role::Member) + .await?; + let name = required_text(params.name, "name")?; + let body_template = required_text(params.body_template, "body_template")?; + let now = chrono::Utc::now(); + sqlx::query_as::<_, IssueTemplate>( + "INSERT INTO issue_template (id, repo_id, name, description, title_template, body_template, \ + labels, active, created_by, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, true, $8, $9, $9) \ + RETURNING id, repo_id, name, description, title_template, body_template, labels, \ + active, created_by, created_at, updated_at", + ) + .bind(Uuid::now_v7()).bind(repo_id).bind(&name).bind(params.description.as_deref()) + .bind(params.title_template.as_deref()).bind(&body_template) + .bind(sqlx::types::Json(¶ms.labels)).bind(user_uid).bind(now) + .fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database) + } + + pub async fn issue_update_template( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + template_id: Uuid, + params: UpdateTemplateParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo_id = self.resolve_repo_id(wk_name, repo_name).await?; + self.ensure_repo_role(repo_id, user_uid, Role::Admin) + .await?; + let current = sqlx::query_as::<_, IssueTemplate>( + "SELECT id, repo_id, name, description, title_template, body_template, labels, \ + active, created_by, created_at, updated_at \ + FROM issue_template WHERE id = $1 AND repo_id = $2", + ) + .bind(template_id) + .bind(repo_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("template not found".into()))?; + + let name = params.name.unwrap_or(current.name); + let description = params.description.or(current.description); + let title_template = params.title_template.or(current.title_template); + let body_template = params.body_template.unwrap_or(current.body_template); + let labels = params.labels.unwrap_or(current.labels); + let active = params.active.unwrap_or(current.active); + let now = chrono::Utc::now(); + + sqlx::query_as::<_, IssueTemplate>( + "UPDATE issue_template SET name = $1, description = $2, title_template = $3, \ + body_template = $4, labels = $5, active = $6, updated_at = $7 WHERE id = $8 \ + RETURNING id, repo_id, name, description, title_template, body_template, labels, \ + active, created_by, created_at, updated_at", + ) + .bind(&name) + .bind(&description) + .bind(&title_template) + .bind(&body_template) + .bind(sqlx::types::Json(&labels)) + .bind(active) + .bind(now) + .bind(template_id) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database) + } + + pub async fn issue_delete_template( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + template_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo_id = self.resolve_repo_id(wk_name, repo_name).await?; + self.ensure_repo_role(repo_id, user_uid, Role::Admin) + .await?; + let result = sqlx::query("DELETE FROM issue_template WHERE id = $1 AND repo_id = $2") + .bind(template_id) + .bind(repo_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "template not found") + } +} diff --git a/service/issues/util.rs b/service/issues/util.rs new file mode 100644 index 0000000..a83877c --- /dev/null +++ b/service/issues/util.rs @@ -0,0 +1,3 @@ +pub use crate::service::util::{ + clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text, role_level, +}; diff --git a/service/mod.rs b/service/mod.rs new file mode 100644 index 0000000..20489f5 --- /dev/null +++ b/service/mod.rs @@ -0,0 +1,121 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::cache::AppCache; +use crate::cache::redis::AppRedis; +use crate::config::AppConfig; +use crate::etcd::EtcdRegistry; +use crate::models::db::AppDatabase; +use crate::queue::NatsQueue; +use crate::service::im::events::ImEventBus; +use crate::storage::s3::AppS3Storage; + +pub mod context; +pub mod util; + +pub mod auth; +pub mod im; +pub mod issues; +pub mod notify; +pub mod pr; +pub mod repo; +pub mod user; +pub mod wiki; +pub mod workspace; + +pub use context::ServiceContext; + +#[derive(Clone)] +pub struct AuthService { + pub ctx: Arc, +} + +#[derive(Clone)] +pub struct UserService { + pub ctx: Arc, +} + +#[derive(Clone)] +pub struct WorkspaceService { + pub ctx: Arc, +} + +#[derive(Clone)] +pub struct RepoService { + pub ctx: Arc, +} + +#[derive(Clone)] +pub struct IssueService { + pub ctx: Arc, +} + +#[derive(Clone)] +pub struct PrService { + pub ctx: Arc, +} + +#[derive(Clone)] +pub struct NotificationService { + pub ctx: Arc, +} + +pub use im::ImService; + +#[derive(Clone)] +pub struct AppService { + pub auth: AuthService, + pub user: UserService, + pub workspace: WorkspaceService, + pub repo: RepoService, + pub issue: IssueService, + pub pr: PrService, + pub notify: NotificationService, + pub im: ImService, + pub ctx: Arc, +} + +impl AppService { + #[allow(clippy::too_many_arguments)] + pub fn new( + version: String, + db: AppDatabase, + redis: AppRedis, + cache: Arc, + config: AppConfig, + storage: AppS3Storage, + registry: Arc, + nats: Arc, + ) -> Self { + let ctx = Arc::new(ServiceContext { + version, + db, + redis, + cache, + config, + storage, + registry, + nats, + im_events: Arc::new(ImEventBus::default()), + }); + + Self { + auth: AuthService { ctx: ctx.clone() }, + user: UserService { ctx: ctx.clone() }, + workspace: WorkspaceService { ctx: ctx.clone() }, + repo: RepoService { ctx: ctx.clone() }, + issue: IssueService { ctx: ctx.clone() }, + pr: PrService { ctx: ctx.clone() }, + notify: NotificationService { ctx: ctx.clone() }, + im: ImService { ctx: ctx.clone() }, + ctx, + } + } +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct Pager { + pub page: i64, + pub per_page: i64, +} diff --git a/service/notify/blocks.rs b/service/notify/blocks.rs new file mode 100644 index 0000000..6108421 --- /dev/null +++ b/service/notify/blocks.rs @@ -0,0 +1,127 @@ +use crate::error::AppError; +use crate::models::common::{DeliveryChannel, NotificationType, TargetType}; +use crate::models::notifications::NotificationBlock; +use crate::service::NotificationService; +use crate::session::Session; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::util::clamp_limit_offset; + +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateBlockParams { + pub workspace_id: Option, + pub repo_id: Option, + pub target_type: TargetType, + pub target_id: Option, + pub notification_type: Option, + pub channel: Option, + pub reason: Option, + pub expires_at: Option>, +} + +impl NotificationBlock { + pub async fn find_by_id( + pool: &sqlx::PgPool, + id: Uuid, + user_id: Uuid, + ) -> Result, AppError> { + sqlx::query_as::<_, NotificationBlock>( + "SELECT id, user_id, workspace_id, repo_id, target_type, target_id, notification_type, \ + channel, reason, expires_at, created_at, updated_at \ + FROM notification_block WHERE id = $1 AND user_id = $2", + ) + .bind(id) + .bind(user_id) + .fetch_optional(pool) + .await + .map_err(AppError::Database) + } + + pub async fn list_for_user( + pool: &sqlx::PgPool, + user_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + sqlx::query_as::<_, NotificationBlock>( + "SELECT id, user_id, workspace_id, repo_id, target_type, target_id, notification_type, \ + channel, reason, expires_at, created_at, updated_at \ + FROM notification_block WHERE user_id = $1 \ + ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + .map_err(AppError::Database) + } +} + +impl NotificationService { + pub async fn list_blocks( + &self, + session: &Session, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let (limit, offset) = clamp_limit_offset(limit, offset); + NotificationBlock::list_for_user(self.ctx.db.reader(), user_id, limit, offset).await + } + + pub async fn create_block( + &self, + session: &Session, + params: CreateBlockParams, + ) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let id = Uuid::now_v7(); + let now = Utc::now(); + + sqlx::query( + "INSERT INTO notification_block \ + (id, user_id, workspace_id, repo_id, target_type, target_id, notification_type, channel, reason, expires_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)", + ) + .bind(id) + .bind(user_id) + .bind(params.workspace_id) + .bind(params.repo_id) + .bind(params.target_type.as_str()) + .bind(params.target_id) + .bind(params.notification_type.map(|t| t.as_str())) + .bind(params.channel.map(|c| c.as_str())) + .bind(params.reason) + .bind(params.expires_at) + .bind(now) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + NotificationBlock::find_by_id(self.ctx.db.reader(), id, user_id) + .await? + .ok_or(AppError::InternalServerError( + "failed to fetch created block".into(), + )) + } + + pub async fn delete_block(&self, session: &Session, block_id: Uuid) -> Result<(), AppError> { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + + let result = sqlx::query("DELETE FROM notification_block WHERE id = $1 AND user_id = $2") + .bind(block_id) + .bind(user_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("block not found".into())); + } + + Ok(()) + } +} diff --git a/service/notify/core.rs b/service/notify/core.rs new file mode 100644 index 0000000..a8ea6ff --- /dev/null +++ b/service/notify/core.rs @@ -0,0 +1,141 @@ +use crate::error::AppError; +use crate::models::notifications::Notification; +use crate::service::NotificationService; +use crate::session::Session; +use chrono::Utc; +use uuid::Uuid; + +use super::util::clamp_limit_offset; + +impl NotificationService { + pub async fn list_notifications( + &self, + session: &Session, + unread_only: bool, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + Notification::list_for_user(self.ctx.db.reader(), user_id, unread_only, limit, offset).await + } + + pub async fn count_unread(&self, session: &Session) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + Notification::count_unread(self.ctx.db.reader(), user_id).await + } + + pub async fn mark_as_read( + &self, + session: &Session, + notification_id: Uuid, + ) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let now = Utc::now(); + + sqlx::query( + "UPDATE notification SET read_at = $1, updated_at = $2 \ + WHERE id = $3 AND user_id = $4 AND deleted_at IS NULL", + ) + .bind(now) + .bind(now) + .bind(notification_id) + .bind(user_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + Notification::find_by_id(self.ctx.db.reader(), notification_id, user_id) + .await? + .ok_or(AppError::NotFound("notification not found".into())) + } + + pub async fn mark_all_as_read(&self, session: &Session) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let now = Utc::now(); + + let result = sqlx::query( + "UPDATE notification SET read_at = $1, updated_at = $2 \ + WHERE user_id = $3 AND deleted_at IS NULL AND read_at IS NULL", + ) + .bind(now) + .bind(now) + .bind(user_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + Ok(result.rows_affected() as i64) + } + + pub async fn dismiss_notification( + &self, + session: &Session, + notification_id: Uuid, + ) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let now = Utc::now(); + + sqlx::query( + "UPDATE notification SET dismissed_at = $1, updated_at = $2 \ + WHERE id = $3 AND user_id = $4 AND deleted_at IS NULL", + ) + .bind(now) + .bind(now) + .bind(notification_id) + .bind(user_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + Notification::find_by_id(self.ctx.db.reader(), notification_id, user_id) + .await? + .ok_or(AppError::NotFound("notification not found".into())) + } + + pub async fn delete_notification( + &self, + session: &Session, + notification_id: Uuid, + ) -> Result<(), AppError> { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let now = Utc::now(); + + let result = sqlx::query( + "UPDATE notification SET deleted_at = $1, updated_at = $2 \ + WHERE id = $3 AND user_id = $4 AND deleted_at IS NULL", + ) + .bind(now) + .bind(now) + .bind(notification_id) + .bind(user_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("notification not found".into())); + } + + Ok(()) + } + + pub async fn clear_all_notifications(&self, session: &Session) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let now = Utc::now(); + + let result = sqlx::query( + "UPDATE notification SET dismissed_at = $1, updated_at = $2 \ + WHERE user_id = $3 AND deleted_at IS NULL AND dismissed_at IS NULL", + ) + .bind(now) + .bind(now) + .bind(user_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + Ok(result.rows_affected() as i64) + } +} diff --git a/service/notify/deliveries.rs b/service/notify/deliveries.rs new file mode 100644 index 0000000..4fcf9b5 --- /dev/null +++ b/service/notify/deliveries.rs @@ -0,0 +1,85 @@ +use crate::error::AppError; +use crate::models::notifications::NotificationDelivery; +use crate::service::NotificationService; +use crate::session::Session; +use uuid::Uuid; + +use super::util::clamp_limit_offset; + +impl NotificationDelivery { + pub async fn list_for_user( + pool: &sqlx::PgPool, + user_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + sqlx::query_as::<_, NotificationDelivery>( + "SELECT id, notification_id, user_id, channel, destination, status, provider, \ + provider_message_id, attempts, last_error, scheduled_at, sent_at, delivered_at, failed_at, created_at, updated_at \ + FROM notification_delivery WHERE user_id = $1 \ + ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + .map_err(AppError::Database) + } + + pub async fn list_for_notification( + pool: &sqlx::PgPool, + notification_id: Uuid, + user_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + sqlx::query_as::<_, NotificationDelivery>( + "SELECT d.id, d.notification_id, d.user_id, d.channel, d.destination, d.status, d.provider, \ + d.provider_message_id, d.attempts, d.last_error, d.scheduled_at, d.sent_at, d.delivered_at, d.failed_at, d.created_at, d.updated_at \ + FROM notification_delivery d \ + JOIN notification n ON d.notification_id = n.id \ + WHERE d.notification_id = $1 AND n.user_id = $2 \ + ORDER BY d.created_at DESC LIMIT $3 OFFSET $4", + ) + .bind(notification_id) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + .map_err(AppError::Database) + } +} + +impl NotificationService { + pub async fn list_deliveries( + &self, + session: &Session, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let (limit, offset) = clamp_limit_offset(limit, offset); + NotificationDelivery::list_for_user(self.ctx.db.reader(), user_id, limit, offset).await + } + + pub async fn list_deliveries_for_notification( + &self, + session: &Session, + notification_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let (limit, offset) = clamp_limit_offset(limit, offset); + NotificationDelivery::list_for_notification( + self.ctx.db.reader(), + notification_id, + user_id, + limit, + offset, + ) + .await + } +} diff --git a/service/notify/mod.rs b/service/notify/mod.rs new file mode 100644 index 0000000..bc29085 --- /dev/null +++ b/service/notify/mod.rs @@ -0,0 +1,6 @@ +pub mod blocks; +pub mod core; +pub mod deliveries; +pub mod subscriptions; +pub mod templates; +pub mod util; diff --git a/service/notify/subscriptions.rs b/service/notify/subscriptions.rs new file mode 100644 index 0000000..886c80f --- /dev/null +++ b/service/notify/subscriptions.rs @@ -0,0 +1,183 @@ +use crate::error::AppError; +use crate::models::common::{EventType, SubscriptionLevel, TargetType}; +use crate::models::notifications::NotificationSubscription; +use crate::service::NotificationService; +use crate::session::Session; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::util::clamp_limit_offset; + +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateSubscriptionParams { + pub workspace_id: Option, + pub repo_id: Option, + pub target_type: TargetType, + pub target_id: Option, + pub event_types: Vec, + pub channels: Vec, + pub level: SubscriptionLevel, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateSubscriptionParams { + pub event_types: Option>, + pub channels: Option>, + pub level: Option, + pub muted: Option, + pub muted_until: Option>, +} + +impl NotificationSubscription { + pub async fn find_by_id( + pool: &sqlx::PgPool, + id: Uuid, + user_id: Uuid, + ) -> Result, AppError> { + sqlx::query_as::<_, NotificationSubscription>( + "SELECT id, user_id, workspace_id, repo_id, target_type, target_id, event_types, \ + channels, level, muted, muted_until, created_at, updated_at \ + FROM notification_subscription WHERE id = $1 AND user_id = $2", + ) + .bind(id) + .bind(user_id) + .fetch_optional(pool) + .await + .map_err(AppError::Database) + } + + pub async fn list_for_user( + pool: &sqlx::PgPool, + user_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + sqlx::query_as::<_, NotificationSubscription>( + "SELECT id, user_id, workspace_id, repo_id, target_type, target_id, event_types, \ + channels, level, muted, muted_until, created_at, updated_at \ + FROM notification_subscription WHERE user_id = $1 \ + ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + .map_err(AppError::Database) + } +} + +impl NotificationService { + pub async fn list_subscriptions( + &self, + session: &Session, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let (limit, offset) = clamp_limit_offset(limit, offset); + NotificationSubscription::list_for_user(self.ctx.db.reader(), user_id, limit, offset).await + } + + pub async fn create_subscription( + &self, + session: &Session, + params: CreateSubscriptionParams, + ) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let id = Uuid::now_v7(); + let now = Utc::now(); + + sqlx::query( + "INSERT INTO notification_subscription \ + (id, user_id, workspace_id, repo_id, target_type, target_id, event_types, channels, level, muted, muted_until, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, false, NULL, $10, $10)", + ) + .bind(id) + .bind(user_id) + .bind(params.workspace_id) + .bind(params.repo_id) + .bind(params.target_type.as_str()) + .bind(params.target_id) + .bind(¶ms.event_types) + .bind(¶ms.channels) + .bind(params.level.as_str()) + .bind(now) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + NotificationSubscription::find_by_id(self.ctx.db.reader(), id, user_id) + .await? + .ok_or(AppError::InternalServerError( + "failed to fetch created subscription".into(), + )) + } + + pub async fn update_subscription( + &self, + session: &Session, + subscription_id: Uuid, + params: UpdateSubscriptionParams, + ) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let now = Utc::now(); + + let existing = + NotificationSubscription::find_by_id(self.ctx.db.reader(), subscription_id, user_id) + .await? + .ok_or(AppError::NotFound("subscription not found".into()))?; + + let event_types = params.event_types.unwrap_or(existing.event_types); + let channels = params.channels.unwrap_or(existing.channels); + let level = params.level.unwrap_or(existing.level); + let muted = params.muted.unwrap_or(existing.muted); + let muted_until = params.muted_until.or(existing.muted_until); + + sqlx::query( + "UPDATE notification_subscription \ + SET event_types = $1, channels = $2, level = $3, muted = $4, muted_until = $5, updated_at = $6 \ + WHERE id = $7 AND user_id = $8", + ) + .bind(&event_types) + .bind(&channels) + .bind(level.as_str()) + .bind(muted) + .bind(muted_until) + .bind(now) + .bind(subscription_id) + .bind(user_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + NotificationSubscription::find_by_id(self.ctx.db.reader(), subscription_id, user_id) + .await? + .ok_or(AppError::InternalServerError( + "failed to fetch updated subscription".into(), + )) + } + + pub async fn delete_subscription( + &self, + session: &Session, + subscription_id: Uuid, + ) -> Result<(), AppError> { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + + let result = + sqlx::query("DELETE FROM notification_subscription WHERE id = $1 AND user_id = $2") + .bind(subscription_id) + .bind(user_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("subscription not found".into())); + } + + Ok(()) + } +} diff --git a/service/notify/templates.rs b/service/notify/templates.rs new file mode 100644 index 0000000..3f46e3c --- /dev/null +++ b/service/notify/templates.rs @@ -0,0 +1,210 @@ +use crate::error::AppError; +use crate::models::common::{DeliveryChannel, NotificationType, Role}; +use crate::models::notifications::NotificationTemplate; +use crate::models::users::User; +use crate::service::NotificationService; +use crate::session::Session; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::util::clamp_limit_offset; + +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateTemplateParams { + pub key: String, + pub notification_type: NotificationType, + pub channel: DeliveryChannel, + pub locale: String, + pub subject_template: Option, + pub title_template: String, + pub body_template: String, + pub action_text_template: Option, + pub enabled: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateTemplateParams { + pub subject_template: Option, + pub title_template: Option, + pub body_template: Option, + pub action_text_template: Option, + pub enabled: Option, +} + +impl NotificationTemplate { + pub async fn find_by_id(pool: &sqlx::PgPool, id: Uuid) -> Result, AppError> { + sqlx::query_as::<_, NotificationTemplate>( + "SELECT id, key, notification_type, channel, locale, subject_template, title_template, \ + body_template, action_text_template, enabled, created_by, created_at, updated_at \ + FROM notification_template WHERE id = $1", + ) + .bind(id) + .fetch_optional(pool) + .await + .map_err(AppError::Database) + } + + pub async fn list_all( + pool: &sqlx::PgPool, + limit: i64, + offset: i64, + ) -> Result, AppError> { + sqlx::query_as::<_, NotificationTemplate>( + "SELECT id, key, notification_type, channel, locale, subject_template, title_template, \ + body_template, action_text_template, enabled, created_by, created_at, updated_at \ + FROM notification_template ORDER BY key, channel, locale LIMIT $1 OFFSET $2", + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + .map_err(AppError::Database) + } +} + +impl NotificationService { + /// Check if user is system admin + async fn ensure_system_admin(&self, session: &Session) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let user: User = sqlx::query_as( + "SELECT id, username, display_name, avatar_url, bio, status, role, visibility, \ + is_active, is_bot, last_login_at, created_at, updated_at, deleted_at \ + FROM \"user\" WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(user_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("User not found".into()))?; + + if user.role != Role::System && user.role != Role::Admin { + return Err(AppError::Forbidden("System admin access required".into())); + } + + Ok(user_id) + } + + pub async fn list_templates( + &self, + session: &Session, + limit: i64, + offset: i64, + ) -> Result, AppError> { + self.ensure_system_admin(session).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + NotificationTemplate::list_all(self.ctx.db.reader(), limit, offset).await + } + + pub async fn get_template( + &self, + session: &Session, + template_id: Uuid, + ) -> Result { + self.ensure_system_admin(session).await?; + NotificationTemplate::find_by_id(self.ctx.db.reader(), template_id) + .await? + .ok_or(AppError::NotFound("template not found".into())) + } + + pub async fn create_template( + &self, + session: &Session, + params: CreateTemplateParams, + ) -> Result { + let user_id = self.ensure_system_admin(session).await?; + let id = Uuid::now_v7(); + let now = chrono::Utc::now(); + + sqlx::query( + "INSERT INTO notification_template \ + (id, key, notification_type, channel, locale, subject_template, title_template, \ + body_template, action_text_template, enabled, created_by, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12)", + ) + .bind(id) + .bind(¶ms.key) + .bind(params.notification_type.as_str()) + .bind(params.channel.as_str()) + .bind(¶ms.locale) + .bind(params.subject_template) + .bind(¶ms.title_template) + .bind(¶ms.body_template) + .bind(params.action_text_template) + .bind(params.enabled) + .bind(user_id) + .bind(now) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + NotificationTemplate::find_by_id(self.ctx.db.reader(), id) + .await? + .ok_or(AppError::InternalServerError( + "failed to fetch created template".into(), + )) + } + + pub async fn update_template( + &self, + session: &Session, + template_id: Uuid, + params: UpdateTemplateParams, + ) -> Result { + self.ensure_system_admin(session).await?; + let now = chrono::Utc::now(); + + let existing = NotificationTemplate::find_by_id(self.ctx.db.reader(), template_id) + .await? + .ok_or(AppError::NotFound("template not found".into()))?; + + let subject_template = params.subject_template.or(existing.subject_template); + let title_template = params.title_template.unwrap_or(existing.title_template); + let body_template = params.body_template.unwrap_or(existing.body_template); + let action_text_template = params + .action_text_template + .or(existing.action_text_template); + let enabled = params.enabled.unwrap_or(existing.enabled); + + sqlx::query( + "UPDATE notification_template \ + SET subject_template = $1, title_template = $2, body_template = $3, \ + action_text_template = $4, enabled = $5, updated_at = $6 \ + WHERE id = $7", + ) + .bind(subject_template) + .bind(&title_template) + .bind(&body_template) + .bind(action_text_template) + .bind(enabled) + .bind(now) + .bind(template_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + NotificationTemplate::find_by_id(self.ctx.db.reader(), template_id) + .await? + .ok_or(AppError::InternalServerError( + "failed to fetch updated template".into(), + )) + } + + pub async fn delete_template( + &self, + session: &Session, + template_id: Uuid, + ) -> Result<(), AppError> { + self.ensure_system_admin(session).await?; + let result = sqlx::query("DELETE FROM notification_template WHERE id = $1") + .bind(template_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("template not found".into())); + } + + Ok(()) + } +} diff --git a/service/notify/util.rs b/service/notify/util.rs new file mode 100644 index 0000000..a9e39cd --- /dev/null +++ b/service/notify/util.rs @@ -0,0 +1,69 @@ +pub use crate::service::util::clamp_limit_offset; + +use crate::error::AppError; +use crate::models::notifications::Notification; +use sqlx::PgPool; +use uuid::Uuid; + +impl Notification { + pub async fn find_by_id( + pool: &PgPool, + id: Uuid, + user_id: Uuid, + ) -> Result, AppError> { + sqlx::query_as::<_, Notification>( + "SELECT id, user_id, actor_id, workspace_id, repo_id, issue_id, pull_request_id, \ + channel_id, message_id, notification_type, title, body, target_type, target_id, \ + action_url, priority, read_at, dismissed_at, metadata, created_at, updated_at, deleted_at \ + FROM notification WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL", + ) + .bind(id) + .bind(user_id) + .fetch_optional(pool) + .await + .map_err(AppError::Database) + } + + pub async fn list_for_user( + pool: &PgPool, + user_id: Uuid, + unread_only: bool, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let sql = if unread_only { + "SELECT id, user_id, actor_id, workspace_id, repo_id, issue_id, pull_request_id, \ + channel_id, message_id, notification_type, title, body, target_type, target_id, \ + action_url, priority, read_at, dismissed_at, metadata, created_at, updated_at, deleted_at \ + FROM notification \ + WHERE user_id = $1 AND deleted_at IS NULL AND read_at IS NULL AND dismissed_at IS NULL \ + ORDER BY created_at DESC LIMIT $2 OFFSET $3" + } else { + "SELECT id, user_id, actor_id, workspace_id, repo_id, issue_id, pull_request_id, \ + channel_id, message_id, notification_type, title, body, target_type, target_id, \ + action_url, priority, read_at, dismissed_at, metadata, created_at, updated_at, deleted_at \ + FROM notification \ + WHERE user_id = $1 AND deleted_at IS NULL AND dismissed_at IS NULL \ + ORDER BY created_at DESC LIMIT $2 OFFSET $3" + }; + + sqlx::query_as::<_, Notification>(sql) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + .map_err(AppError::Database) + } + + pub async fn count_unread(pool: &PgPool, user_id: Uuid) -> Result { + sqlx::query_scalar( + "SELECT COUNT(*) FROM notification \ + WHERE user_id = $1 AND deleted_at IS NULL AND read_at IS NULL AND dismissed_at IS NULL", + ) + .bind(user_id) + .fetch_one(pool) + .await + .map_err(AppError::Database) + } +} diff --git a/service/pr/assignees.rs b/service/pr/assignees.rs new file mode 100644 index 0000000..5326b8b --- /dev/null +++ b/service/pr/assignees.rs @@ -0,0 +1,119 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::prs::PrAssignee; +use crate::service::PrService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected}; + +impl PrService { + pub async fn pr_assignees( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, PrAssignee>( + "SELECT id, pull_request_id, assignee_id, assigned_by, created_at \ + FROM pr_assignee WHERE pull_request_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(pr.id).bind(limit).bind(offset) + .fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database) + } + + pub async fn pr_assign( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + assignee_id: Uuid, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_editable(user_uid, &pr).await?; + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let assignee = sqlx::query_as::<_, PrAssignee>( + "INSERT INTO pr_assignee (id, pull_request_id, assignee_id, assigned_by, created_at) \ + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (pull_request_id, assignee_id) DO NOTHING \ + RETURNING id, pull_request_id, assignee_id, assigned_by, created_at", + ) + .bind(Uuid::now_v7()) + .bind(pr.id) + .bind(assignee_id) + .bind(user_uid) + .bind(now) + .fetch_optional(&mut *txn) + .await + .map_err(AppError::Database)? + .ok_or(AppError::Conflict("user already assigned".into()))?; + + sqlx::query( + "INSERT INTO pr_subscription (id, pull_request_id, user_id, reason, muted, created_at, updated_at) \ + VALUES ($1, $2, $3, 'assignee', false, $4, $4) ON CONFLICT DO NOTHING", + ) + .bind(Uuid::now_v7()).bind(pr.id).bind(assignee_id).bind(now) + .execute(&mut *txn).await.map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(assignee) + } + + pub async fn pr_unassign( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + assignee_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_editable(user_uid, &pr).await?; + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = + sqlx::query("DELETE FROM pr_assignee WHERE pull_request_id = $1 AND assignee_id = $2") + .bind(pr.id) + .bind(assignee_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "assignee not found")?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } +} diff --git a/service/pr/check_runs.rs b/service/pr/check_runs.rs new file mode 100644 index 0000000..7d49c08 --- /dev/null +++ b/service/pr/check_runs.rs @@ -0,0 +1,189 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::{Role, Status}; +use crate::models::prs::PrCheckRun; +use crate::service::PrService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, required_text}; + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateCheckRunParams { + pub commit_sha: String, + pub name: String, + pub status: String, + pub conclusion: Option, + pub details_url: Option, + pub external_id: Option, +} + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateCheckRunParams { + pub status: Option, + pub conclusion: Option, + pub details_url: Option, +} + +impl PrService { + pub async fn pr_check_runs( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, PrCheckRun>( + "SELECT id, pull_request_id, commit_sha, name, status, conclusion, details_url, \ + external_id, started_at, completed_at, created_at, updated_at \ + FROM pr_check_run WHERE pull_request_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(pr.id).bind(limit).bind(offset) + .fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database) + } + + pub async fn pr_create_check_run( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + params: CreateCheckRunParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + + let name = required_text(params.name, "name")?; + let status = params + .status + .trim() + .parse::() + .map_err(|_| AppError::BadRequest("invalid status".into()))?; + if status == Status::Unknown { + return Err(AppError::BadRequest("invalid status".into())); + } + let conclusion = params + .conclusion + .as_deref() + .and_then(|s| s.parse::().ok()) + .filter(|s| *s != Status::Unknown); + + let now = chrono::Utc::now(); + sqlx::query_as::<_, PrCheckRun>( + "INSERT INTO pr_check_run (id, pull_request_id, commit_sha, name, status, conclusion, \ + details_url, external_id, started_at, completed_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) \ + RETURNING id, pull_request_id, commit_sha, name, status, conclusion, details_url, \ + external_id, started_at, completed_at, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(pr.id) + .bind(¶ms.commit_sha) + .bind(&name) + .bind(status) + .bind(conclusion) + .bind(params.details_url.as_deref()) + .bind(params.external_id.as_deref()) + .bind(if status == Status::Running { + Some(now) + } else { + None + }) + .bind( + if matches!(status, Status::Completed | Status::Success | Status::Failed) { + Some(now) + } else { + None + }, + ) + .bind(now) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database) + } + + pub async fn pr_update_check_run( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + check_run_id: Uuid, + params: UpdateCheckRunParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + + let current = sqlx::query_as::<_, PrCheckRun>( + "SELECT id, pull_request_id, commit_sha, name, status, conclusion, details_url, \ + external_id, started_at, completed_at, created_at, updated_at \ + FROM pr_check_run WHERE id = $1 AND pull_request_id = $2", + ) + .bind(check_run_id) + .bind(pr.id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("check run not found".into()))?; + + let status = match params.status { + Some(ref v) => v + .trim() + .parse::() + .map_err(|_| AppError::BadRequest("invalid status".into()))?, + None => current.status, + }; + let conclusion = params + .conclusion + .as_deref() + .and_then(|s| s.parse::().ok()) + .filter(|s| *s != Status::Unknown) + .or(current.conclusion); + let details_url = params.details_url.or(current.details_url); + let now = chrono::Utc::now(); + + sqlx::query_as::<_, PrCheckRun>( + "UPDATE pr_check_run SET status = $1, conclusion = $2, details_url = $3, updated_at = $4 \ + WHERE id = $5 \ + RETURNING id, pull_request_id, commit_sha, name, status, conclusion, details_url, \ + external_id, started_at, completed_at, created_at, updated_at", + ) + .bind(status).bind(conclusion).bind(details_url.as_deref()).bind(now).bind(check_run_id) + .fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database) + } + + pub async fn pr_delete_check_run( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + check_run_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + + let result = sqlx::query("DELETE FROM pr_check_run WHERE id = $1 AND pull_request_id = $2") + .bind(check_run_id) + .bind(pr.id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "check run not found") + } +} diff --git a/service/pr/commits.rs b/service/pr/commits.rs new file mode 100644 index 0000000..b7c03c5 --- /dev/null +++ b/service/pr/commits.rs @@ -0,0 +1,29 @@ +use crate::error::AppError; +use crate::models::prs::PrCommit; +use crate::service::PrService; +use crate::session::Session; + +use super::util::clamp_limit_offset; + +impl PrService { + pub async fn pr_commits( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, PrCommit>( + "SELECT id, pull_request_id, repo_id, commit_sha, position, authored_at, committed_at, created_at \ + FROM pr_commit WHERE pull_request_id = $1 ORDER BY position ASC LIMIT $2 OFFSET $3", + ) + .bind(pr.id).bind(limit).bind(offset) + .fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database) + } +} diff --git a/service/pr/core.rs b/service/pr/core.rs new file mode 100644 index 0000000..7713769 --- /dev/null +++ b/service/pr/core.rs @@ -0,0 +1,1084 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::{Role, State}; +use crate::models::prs::PullRequest; +use crate::models::repos::Repo; +use crate::models::workspaces::Workspace; +use crate::service::PrService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, merge_optional_text}; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreatePrParams { + pub title: String, + pub body: Option, + pub source_repo_id: Uuid, + pub source_branch: String, + pub target_branch: String, + pub head_commit_sha: String, + pub base_commit_sha: Option, + pub draft: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdatePrParams { + pub title: Option, + pub body: Option, + pub target_branch: Option, + pub draft: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct MergePrParams { + pub strategy: Option, + pub squash_title: Option, + pub squash_message: Option, + pub delete_source_branch: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct PrListFilters { + pub state: Option, + pub author_id: Option, + pub draft: Option, +} + +impl PrService { + pub async fn pr_list( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + filters: PrListFilters, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + let state = filters + .state + .as_deref() + .and_then(|s| s.parse::().ok()) + .filter(|s| *s != State::Unknown); + + sqlx::query_as::<_, PullRequest>( + "SELECT id, repo_id, author_id, number, title, body, state, source_repo_id, \ + source_branch, target_repo_id, target_branch, base_commit_sha, head_commit_sha, \ + merge_commit_sha, draft, locked, merged_by, merged_at, closed_by, closed_at, \ + created_at, updated_at, deleted_at \ + FROM pull_request WHERE repo_id = $1 AND deleted_at IS NULL \ + AND ($2::text IS NULL OR state::text = $2) \ + AND ($3::uuid IS NULL OR author_id = $3) \ + AND ($4::bool IS NULL OR draft = $4) \ + ORDER BY number DESC LIMIT $5 OFFSET $6", + ) + .bind(repo.id) + .bind(state.map(|s| s.to_string())) + .bind(filters.author_id) + .bind(filters.draft) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn pr_get( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + let repo = Repo::find_by_id(self.ctx.db.reader(), pr.repo_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("repo not found".into()))?; + self.ensure_repo_readable(user_uid, &repo).await?; + Ok(pr) + } + + #[tracing::instrument(skip(self, ctx, params), fields(title = %params.title))] + pub async fn pr_create( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: CreatePrParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + let title = crate::service::util::required_text(params.title, "title")?; + + // Validate source repo exists and is readable + let source_repo = Repo::find_by_id(self.ctx.db.reader(), params.source_repo_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("Source repository not found".into()))?; + + self.ensure_repo_readable(user_uid, &source_repo).await?; + + // Validate target branch exists + if !self.branch_exists(&repo, ¶ms.target_branch).await? { + return Err(AppError::BadRequest(format!( + "Target branch '{}' does not exist", + params.target_branch + ))); + } + + // Validate source branch exists + if !self + .branch_exists(&source_repo, ¶ms.source_branch) + .await? + { + return Err(AppError::BadRequest(format!( + "Source branch '{}' does not exist", + params.source_branch + ))); + } + + // Validate head commit exists in source repo + if !self + .commit_exists(&source_repo, ¶ms.head_commit_sha) + .await? + { + return Err(AppError::BadRequest(format!( + "Head commit '{}' does not exist", + params.head_commit_sha + ))); + } + + // For cross-repo PRs, validate fork relationship + if source_repo.id != repo.id && source_repo.forked_from_repo_id != Some(repo.id) { + return Err(AppError::BadRequest( + "Source repository is not a fork of the target repository".into(), + )); + } + + let now = chrono::Utc::now(); + let pr_id = Uuid::now_v7(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let number = PullRequest::next_number(&mut *txn, repo.id) + .await + .map_err(AppError::Database)?; + + let pr = sqlx::query_as::<_, PullRequest>( + "INSERT INTO pull_request (id, repo_id, author_id, number, title, body, state, \ + source_repo_id, source_branch, target_repo_id, target_branch, base_commit_sha, \ + head_commit_sha, merge_commit_sha, draft, locked, merged_by, merged_at, \ + closed_by, closed_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, 'open', $7, $8, $9, $10, $11, $12, NULL, $13, false, \ + NULL, NULL, NULL, NULL, $14, $14) \ + RETURNING id, repo_id, author_id, number, title, body, state, source_repo_id, \ + source_branch, target_repo_id, target_branch, base_commit_sha, head_commit_sha, \ + merge_commit_sha, draft, locked, merged_by, merged_at, closed_by, closed_at, \ + created_at, updated_at, deleted_at", + ) + .bind(pr_id) + .bind(repo.id) + .bind(user_uid) + .bind(number) + .bind(&title) + .bind(params.body.as_deref()) + .bind(params.source_repo_id) + .bind(¶ms.source_branch) + .bind(repo.id) + .bind(¶ms.target_branch) + .bind(params.base_commit_sha.as_deref()) + .bind(¶ms.head_commit_sha) + .bind(params.draft.unwrap_or(false)) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO pr_status (pull_request_id, head_commit_sha, checks_state, mergeable_state, \ + conflicts, approvals_count, requested_reviews_count, changed_files_count, \ + additions_count, deletions_count, updated_at) \ + VALUES ($1, $2, 'pending', 'unknown', false, 0, 0, 0, 0, 0, $3)", + ) + .bind(pr_id) + .bind(¶ms.head_commit_sha) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO pr_subscription (id, pull_request_id, user_id, reason, muted, created_at, updated_at) \ + VALUES ($1, $2, $3, 'author', false, $4, $4)", + ) + .bind(Uuid::now_v7()) + .bind(pr_id) + .bind(user_uid) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE repo_stats SET open_pull_requests_count = open_pull_requests_count + 1, updated_at = $1 WHERE repo_id = $2", + ) + .bind(now) + .bind(repo.id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + tracing::info!(pr_id = %pr_id, number = number, "Pull request created"); + Ok(pr) + } + + pub async fn pr_update( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + params: UpdatePrParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_editable(user_uid, &pr).await?; + + let title = + merge_optional_text(params.title, Some(pr.title.clone())).unwrap_or(pr.title.clone()); + let body = merge_optional_text(params.body, pr.body.clone()); + let target_branch = params.target_branch.unwrap_or(pr.target_branch.clone()); + let draft = params.draft.unwrap_or(pr.draft); + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, PullRequest>( + "UPDATE pull_request SET title = $1, body = $2, target_branch = $3, draft = $4, updated_at = $5 \ + WHERE id = $6 AND deleted_at IS NULL \ + RETURNING id, repo_id, author_id, number, title, body, state, source_repo_id, \ + source_branch, target_repo_id, target_branch, base_commit_sha, head_commit_sha, \ + merge_commit_sha, draft, locked, merged_by, merged_at, closed_by, closed_at, \ + created_at, updated_at, deleted_at", + ) + .bind(&title).bind(&body).bind(&target_branch).bind(draft).bind(now).bind(pr.id) + .fetch_one(&mut *txn).await.map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn pr_mark_ready( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_editable(user_uid, &pr).await?; + + if !pr.draft { + return Err(AppError::BadRequest( + "PR is already ready for review".into(), + )); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, PullRequest>( + "UPDATE pull_request SET draft = false, updated_at = $1 \ + WHERE id = $2 AND deleted_at IS NULL \ + RETURNING id, repo_id, author_id, number, title, body, state, source_repo_id, \ + source_branch, target_repo_id, target_branch, base_commit_sha, head_commit_sha, \ + merge_commit_sha, draft, locked, merged_by, merged_at, closed_by, closed_at, \ + created_at, updated_at, deleted_at", + ) + .bind(now) + .bind(pr.id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + self.create_pr_event( + pr.id, + Some(user_uid), + crate::models::common::EventType::DraftReady, + Some(serde_json::json!({"draft": true})), + Some(serde_json::json!({"draft": false})), + None, + ) + .await?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn pr_close( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_editable(user_uid, &pr).await?; + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, PullRequest>( + "UPDATE pull_request SET state = 'closed', closed_by = $1, closed_at = $2, updated_at = $2 \ + WHERE id = $3 AND deleted_at IS NULL AND state = 'open' \ + RETURNING id, repo_id, author_id, number, title, body, state, source_repo_id, \ + source_branch, target_repo_id, target_branch, base_commit_sha, head_commit_sha, \ + merge_commit_sha, draft, locked, merged_by, merged_at, closed_by, closed_at, \ + created_at, updated_at, deleted_at", + ) + .bind(user_uid).bind(now).bind(pr.id) + .fetch_optional(&mut *txn).await.map_err(AppError::Database)? + .ok_or(AppError::NotFound("PR not found or already closed".into()))?; + + sqlx::query("UPDATE repo_stats SET open_pull_requests_count = GREATEST(open_pull_requests_count - 1, 0), updated_at = $1 WHERE repo_id = $2") + .bind(now).bind(pr.repo_id).execute(&mut *txn).await.map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn pr_reopen( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_editable(user_uid, &pr).await?; + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, PullRequest>( + "UPDATE pull_request SET state = 'open', closed_by = NULL, closed_at = NULL, updated_at = $1 \ + WHERE id = $2 AND deleted_at IS NULL AND state = 'closed' AND merged_at IS NULL \ + RETURNING id, repo_id, author_id, number, title, body, state, source_repo_id, \ + source_branch, target_repo_id, target_branch, base_commit_sha, head_commit_sha, \ + merge_commit_sha, draft, locked, merged_by, merged_at, closed_by, closed_at, \ + created_at, updated_at, deleted_at", + ) + .bind(now).bind(pr.id) + .fetch_optional(&mut *txn).await.map_err(AppError::Database)? + .ok_or(AppError::NotFound("PR not found, not closed, or already merged".into()))?; + + sqlx::query("UPDATE repo_stats SET open_pull_requests_count = open_pull_requests_count + 1, updated_at = $1 WHERE repo_id = $2") + .bind(now).bind(pr.repo_id).execute(&mut *txn).await.map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn pr_delete( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_admin(user_uid, &pr).await?; + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query("UPDATE pull_request SET deleted_at = $1, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL") + .bind(now).bind(pr.id).execute(&mut *txn).await.map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "PR not found")?; + + if pr.state == State::Open { + sqlx::query("UPDATE repo_stats SET open_pull_requests_count = GREATEST(open_pull_requests_count - 1, 0), updated_at = $1 WHERE repo_id = $2") + .bind(now).bind(pr.repo_id).execute(&mut *txn).await.map_err(AppError::Database)?; + } + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn pr_lock( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + locked: bool, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_editable(user_uid, &pr).await?; + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, PullRequest>( + "UPDATE pull_request SET locked = $1, updated_at = $2 WHERE id = $3 AND deleted_at IS NULL \ + RETURNING id, repo_id, author_id, number, title, body, state, source_repo_id, \ + source_branch, target_repo_id, target_branch, base_commit_sha, head_commit_sha, \ + merge_commit_sha, draft, locked, merged_by, merged_at, closed_by, closed_at, \ + created_at, updated_at, deleted_at", + ) + .bind(locked).bind(now).bind(pr.id) + .fetch_one(&mut *txn).await.map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn pr_merge( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + params: MergePrParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + let repo = Repo::find_by_id(self.ctx.db.reader(), pr.repo_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("Repo not found".into()))?; + + // Require at least Maintainer role for merge (Admin for protected branches) + let user_role = self + .ensure_repo_role_at_least(user_uid, &repo, Role::Maintainer) + .await?; + + if pr.state != State::Open { + return Err(AppError::BadRequest("PR is not open".into())); + } + if pr.draft { + return Err(AppError::BadRequest("cannot merge a draft PR".into())); + } + + // Check branch protection rules + let ws = self.resolve_workspace(wk_name).await?; + let protection_rule = self + .check_branch_protection(&repo, &pr.target_branch, &ws) + .await?; + + if let Some(rule) = &protection_rule { + // Admins can bypass protection rules, others must satisfy them + if crate::service::util::role_level(user_role) + < crate::service::util::role_level(Role::Admin) + { + // Check approval count; author self-approval must not satisfy protection. + let approval_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM pr_review \ + WHERE pull_request_id = $1 AND state = 'approved' AND dismissed_at IS NULL \ + AND submitted_at IS NOT NULL AND author_id <> $2", + ) + .bind(pr.id) + .bind(pr.author_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if approval_count < rule.require_approvals as i64 { + return Err(AppError::BadRequest(format!( + "PR requires {} approvals, only {} received", + rule.require_approvals, approval_count + ))); + } + + // Check required status checks + if rule.require_status_checks && !rule.required_status_checks.is_empty() { + let required_checks = &rule.required_status_checks; + + // Get all check runs for the head commit + let passed_checks: Vec = sqlx::query_scalar( + "SELECT name FROM pr_check_run \ + WHERE pull_request_id = $1 AND commit_sha = $2 AND status = 'success'", + ) + .bind(pr.id) + .bind(&pr.head_commit_sha) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + for required_check in required_checks { + if !passed_checks.contains(required_check) { + return Err(AppError::BadRequest(format!( + "Required status check '{}' has not passed", + required_check + ))); + } + } + } + + // Prevent self-merge unless explicitly allowed + if rule.require_approvals > 0 && pr.author_id == user_uid { + return Err(AppError::BadRequest( + "Cannot self-merge PRs that require approvals".to_string(), + )); + } + } + } + + // Perform actual Git merge via RPC + let merge_result = self.perform_git_merge(&repo, &pr, &ws, ¶ms).await?; + + let merge_commit_sha = merge_result.commit_sha; + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, PullRequest>( + "UPDATE pull_request SET state = 'merged', merged_by = $1, merged_at = $2, \ + merge_commit_sha = $3, closed_by = $1, closed_at = $2, updated_at = $2 \ + WHERE id = $4 AND deleted_at IS NULL AND state = 'open' \ + RETURNING id, repo_id, author_id, number, title, body, state, source_repo_id, \ + source_branch, target_repo_id, target_branch, base_commit_sha, head_commit_sha, \ + merge_commit_sha, draft, locked, merged_by, merged_at, closed_by, closed_at, \ + created_at, updated_at, deleted_at", + ) + .bind(user_uid) + .bind(now) + .bind(&merge_commit_sha) + .bind(pr.id) + .fetch_optional(&mut *txn) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("PR not found or already merged".into()))?; + + sqlx::query("UPDATE repo_stats SET open_pull_requests_count = GREATEST(open_pull_requests_count - 1, 0), updated_at = $1 WHERE repo_id = $2") + .bind(now).bind(pr.repo_id).execute(&mut *txn).await.map_err(AppError::Database)?; + + // Delete source branch if requested + if params.delete_source_branch.unwrap_or(false) { + // Only delete if source and target are in the same repo + if pr.source_repo_id == pr.target_repo_id { + let _ = self.delete_git_branch(&repo, &ws, &pr.source_branch).await; + let _ = sqlx::query("DELETE FROM repo_branch WHERE repo_id = $1 AND name = $2") + .bind(pr.source_repo_id) + .bind(&pr.source_branch) + .execute(&mut *txn) + .await; + } + } + + txn.commit().await.map_err(|_| AppError::TxnError)?; + tracing::info!(pr_id = %pr.id, number = number, merge_sha = %merge_commit_sha, "Pull request merged"); + Ok(result) + } + + pub(crate) async fn resolve_workspace(&self, wk_name: &str) -> Result { + Workspace::find_by_name(self.ctx.db.reader(), wk_name) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("workspace not found".into())) + } + + pub(crate) async fn resolve_repo( + &self, + wk_name: &str, + repo_name: &str, + ) -> Result { + let ws = self.resolve_workspace(wk_name).await?; + Repo::find_by_name(self.ctx.db.reader(), ws.id, repo_name) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("repo not found".into())) + } + + pub(crate) async fn resolve_pr( + &self, + wk_name: &str, + repo_name: &str, + number: i64, + ) -> Result { + let repo = self.resolve_repo(wk_name, repo_name).await?; + PullRequest::find_by_number(self.ctx.db.reader(), repo.id, number) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("PR not found".into())) + } + + pub(crate) async fn ensure_repo_readable( + &self, + user_uid: Uuid, + repo: &Repo, + ) -> Result<(), AppError> { + if Repo::is_readable(self.ctx.db.reader(), repo, user_uid) + .await + .map_err(AppError::Database)? + { + Ok(()) + } else { + Err(AppError::Unauthorized) + } + } + + pub(crate) async fn ensure_repo_role_at_least( + &self, + user_uid: Uuid, + repo: &Repo, + min_role: Role, + ) -> Result { + let role = Repo::user_role(self.ctx.db.reader(), repo.id, user_uid, repo.owner_id) + .await + .map_err(AppError::Database)? + .unwrap_or(Role::Unknown); + if crate::service::util::role_level(role) < crate::service::util::role_level(min_role) { + return Err(AppError::Unauthorized); + } + Ok(role) + } + + pub(crate) async fn ensure_pr_readable( + &self, + user_uid: Uuid, + pr: &PullRequest, + ) -> Result<(), AppError> { + let repo = Repo::find_by_id(self.ctx.db.reader(), pr.repo_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("repo not found".into()))?; + self.ensure_repo_readable(user_uid, &repo).await + } + + pub(crate) async fn ensure_pr_editable( + &self, + user_uid: Uuid, + pr: &PullRequest, + ) -> Result<(), AppError> { + if pr.author_id == user_uid { + return Ok(()); + } + let repo = Repo::find_by_id(self.ctx.db.reader(), pr.repo_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("repo not found".into()))?; + let role = Repo::user_role(self.ctx.db.reader(), repo.id, user_uid, repo.owner_id) + .await + .map_err(AppError::Database)? + .unwrap_or(Role::Unknown); + if crate::service::util::role_level(role) >= crate::service::util::role_level(Role::Member) + { + return Ok(()); + } + Err(AppError::Unauthorized) + } + + pub(crate) async fn ensure_pr_admin( + &self, + user_uid: Uuid, + pr: &PullRequest, + ) -> Result<(), AppError> { + let repo = Repo::find_by_id(self.ctx.db.reader(), pr.repo_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("repo not found".into()))?; + let role = Repo::user_role(self.ctx.db.reader(), repo.id, user_uid, repo.owner_id) + .await + .map_err(AppError::Database)? + .unwrap_or(Role::Unknown); + if crate::service::util::role_level(role) >= crate::service::util::role_level(Role::Admin) { + return Ok(()); + } + Err(AppError::Unauthorized) + } + + /// Check branch protection rules for a given branch + async fn check_branch_protection( + &self, + repo: &Repo, + branch_name: &str, + _ws: &Workspace, + ) -> Result, AppError> { + use crate::models::repos::BranchProtectionRule; + + let rules: Vec = sqlx::query_as( + "SELECT id, repo_id, pattern, require_approvals, require_status_checks, \ + required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \ + require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \ + restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \ + require_conversation_resolution, created_by, created_at, updated_at \ + FROM branch_protection_rule WHERE repo_id = $1 ORDER BY pattern ASC", + ) + .bind(repo.id) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + Ok(rules + .into_iter() + .find(|rule| glob_match(&rule.pattern, branch_name))) + } + + /// Check if a branch exists in the repository + async fn branch_exists(&self, repo: &Repo, branch_name: &str) -> Result { + use crate::pb::repo::{self as pb}; + + let ws = self.resolve_workspace_by_repo(repo).await?; + let git_client = self + .ctx + .registry + .get_git_client(&repo.primary_storage_node_id) + .ok_or(AppError::Config("Git client not available".into()))?; + + let header = pb::RepositoryHeader { + storage_name: ws.name.clone(), + relative_path: format!("{}.git", repo.name), + storage_path: repo.storage_path.clone(), + }; + + let request = pb::GetBranchRequest { + repository: Some(header), + name: branch_name.to_string(), + }; + + let mut client = git_client; + match client.branch.get_branch(tonic::Request::new(request)).await { + Ok(_) => Ok(true), + Err(status) if status.code() == tonic::Code::NotFound => Ok(false), + Err(e) => Err(AppError::InternalServerError(format!( + "Failed to check branch: {}", + e + ))), + } + } + + /// Check if a commit exists in the repository + async fn commit_exists(&self, repo: &Repo, commit_sha: &str) -> Result { + use crate::pb::repo::{self as pb}; + + let ws = self.resolve_workspace_by_repo(repo).await?; + let git_client = self + .ctx + .registry + .get_git_client(&repo.primary_storage_node_id) + .ok_or(AppError::Config("Git client not available".into()))?; + + let header = pb::RepositoryHeader { + storage_name: ws.name.clone(), + relative_path: format!("{}.git", repo.name), + storage_path: repo.storage_path.clone(), + }; + + let request = pb::GetCommitRequest { + repository: Some(header), + revision: Some(pb::ObjectSelector { + selector: Some(pb::object_selector::Selector::Revision(pb::ObjectName { + revision: commit_sha.to_string(), + })), + }), + include_stats: false, + include_raw: false, + }; + + let mut client = git_client; + match client.commit.get_commit(tonic::Request::new(request)).await { + Ok(_) => Ok(true), + Err(status) if status.code() == tonic::Code::NotFound => Ok(false), + Err(e) => Err(AppError::InternalServerError(format!( + "Failed to check commit: {}", + e + ))), + } + } + + /// Get workspace for a repository + async fn resolve_workspace_by_repo(&self, repo: &Repo) -> Result { + sqlx::query_as::<_, Workspace>( + "SELECT id, owner_id, name, description, avatar_url, visibility, plan, status, \ + default_role, is_personal, archived_at, created_at, updated_at, deleted_at \ + FROM workspace WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(repo.workspace_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("Workspace not found".into())) + } + + /// Perform actual git merge via RPC + async fn perform_git_merge( + &self, + repo: &Repo, + pr: &PullRequest, + ws: &Workspace, + params: &MergePrParams, + ) -> Result { + use crate::pb::repo::{self as pb}; + + let mut git_client = self + .ctx + .registry + .get_git_client(&repo.primary_storage_node_id) + .ok_or(AppError::Config("Git client not available".into()))?; + + let header = pb::RepositoryHeader { + storage_name: ws.name.clone(), + relative_path: format!("{}.git", repo.name), + storage_path: repo.storage_path.clone(), + }; + + // Determine merge strategy + let strategy = params.strategy.as_deref().unwrap_or("merge"); + let merge_strategy = match strategy { + "squash" => pb::merge_options::Strategy::MergeStrategyOrt as i32, + "rebase" => pb::merge_options::Strategy::MergeStrategyRecursive as i32, + _ => pb::merge_options::Strategy::MergeStrategyOrt as i32, + }; + + let options = pb::MergeOptions { + strategy: merge_strategy, + fast_forward: if strategy == "rebase" { + pb::merge_options::FastForwardMode::MergeFastForwardModeNoFf as i32 + } else { + pb::merge_options::FastForwardMode::MergeFastForwardModeAllowed as i32 + }, + squash: strategy == "squash", + no_commit: false, + allow_unrelated_histories: false, + strategy_options: vec![], + }; + + let request = pb::MergeRequest { + repository: Some(header), + target_branch: pr.target_branch.clone(), + source: Some(pb::ObjectSelector { + selector: Some(pb::object_selector::Selector::Revision(pb::ObjectName { + revision: pr.head_commit_sha.clone(), + })), + }), + committer: None, + message: params.squash_message.clone().unwrap_or_else(|| { + if strategy == "squash" { + params + .squash_title + .clone() + .unwrap_or_else(|| format!("Squash merge PR #{}", pr.number)) + } else { + format!( + "Merge pull request #{} from {}", + pr.number, pr.source_branch + ) + } + }), + options: Some(options), + }; + + let response = git_client + .merge + .merge(tonic::Request::new(request)) + .await + .map_err(|e| AppError::InternalServerError(format!("Git merge failed: {}", e)))?; + + let merge_response = response.into_inner(); + + // Check if merge was successful + let status = merge_response.status(); + if status != pb::merge_result::Status::MergeResultStatusMerged + && status != pb::merge_result::Status::MergeResultStatusFastForward + { + return Err(AppError::InternalServerError(format!( + "Git merge failed with status: {:?}", + status + ))); + } + + let commit_sha = merge_response + .commit + .and_then(|c| c.oid) + .and_then(|oid| oid.hex.into()) + .ok_or(AppError::InternalServerError( + "Git merge did not return commit SHA".into(), + ))?; + + Ok(MergeResult { + commit_sha, + merged: true, + }) + } + + /// Delete a git branch via RPC + async fn delete_git_branch( + &self, + repo: &Repo, + ws: &Workspace, + branch_name: &str, + ) -> Result<(), AppError> { + use crate::pb::repo::{self as pb}; + + let mut git_client = self + .ctx + .registry + .get_git_client(&repo.primary_storage_node_id) + .ok_or(AppError::Config("Git client not available".into()))?; + + let header = pb::RepositoryHeader { + storage_name: ws.name.clone(), + relative_path: format!("{}.git", repo.name), + storage_path: repo.storage_path.clone(), + }; + + let request = pb::DeleteBranchRequest { + repository: Some(header), + name: branch_name.to_string(), + force: false, + }; + + git_client + .branch + .delete_branch(tonic::Request::new(request)) + .await + .map_err(|e| { + AppError::InternalServerError(format!("Failed to delete branch: {}", e)) + })?; + + Ok(()) + } +} + +fn glob_match(pattern: &str, text: &str) -> bool { + if pattern == text || pattern == "*" { + return true; + } + + let p: Vec = pattern.chars().collect(); + let t: Vec = text.chars().collect(); + let (mut pi, mut ti) = (0usize, 0usize); + let (mut star_pi, mut star_ti) = (None, None); + + loop { + if pi < p.len() && ti < t.len() && (p[pi] == '?' || p[pi] == t[ti]) { + pi += 1; + ti += 1; + continue; + } + if pi < p.len() && p[pi] == '*' { + star_pi = Some(pi); + star_ti = Some(ti); + pi += 1; + continue; + } + if let (Some(sp), Some(st)) = (star_pi, star_ti) + && st < t.len() + { + pi = sp + 1; + let next_ti = st + 1; + star_ti = Some(next_ti); + ti = next_ti; + continue; + } + return pi == p.len() && ti == t.len(); + } +} + +/// Result of a git merge operation +#[allow(dead_code)] +struct MergeResult { + commit_sha: String, + merged: bool, +} diff --git a/service/pr/events.rs b/service/pr/events.rs new file mode 100644 index 0000000..fb76cd9 --- /dev/null +++ b/service/pr/events.rs @@ -0,0 +1,73 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::{EventType, JsonValue}; +use crate::models::prs::PrEvent; +use crate::service::PrService; +use crate::session::Session; + +use super::util::clamp_limit_offset; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreatePrEventParams { + pub event_type: String, + pub old_value: Option, + pub new_value: Option, + pub metadata: Option, +} + +impl PrService { + pub async fn pr_list_events( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + sqlx::query_as::<_, PrEvent>( + "SELECT id, pull_request_id, actor_id, event_type, old_value, new_value, metadata, created_at \ + FROM pr_event WHERE pull_request_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(pr.id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub(crate) async fn create_pr_event( + &self, + pull_request_id: Uuid, + actor_id: Option, + event_type: EventType, + old_value: Option, + new_value: Option, + metadata: Option, + ) -> Result { + sqlx::query_as::<_, PrEvent>( + "INSERT INTO pr_event (id, pull_request_id, actor_id, event_type, old_value, new_value, metadata, created_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \ + RETURNING id, pull_request_id, actor_id, event_type, old_value, new_value, metadata, created_at", + ) + .bind(Uuid::now_v7()) + .bind(pull_request_id) + .bind(actor_id) + .bind(event_type) + .bind(old_value) + .bind(new_value) + .bind(metadata) + .bind(chrono::Utc::now()) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database) + } +} diff --git a/service/pr/files.rs b/service/pr/files.rs new file mode 100644 index 0000000..cf17f70 --- /dev/null +++ b/service/pr/files.rs @@ -0,0 +1,34 @@ +use crate::error::AppError; +use crate::models::prs::PrFile; +use crate::service::PrService; +use crate::session::Session; + +use super::util::clamp_limit_offset; + +impl PrService { + pub async fn pr_files( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, PrFile>( + "SELECT id, pull_request_id, path, old_path, status, additions, deletions, changes, \ + patch, created_at, updated_at \ + FROM pr_file WHERE pull_request_id = $1 ORDER BY path ASC LIMIT $2 OFFSET $3", + ) + .bind(pr.id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } +} diff --git a/service/pr/labels.rs b/service/pr/labels.rs new file mode 100644 index 0000000..c1e7db9 --- /dev/null +++ b/service/pr/labels.rs @@ -0,0 +1,225 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::prs::{PrLabel, PrLabelRelation}; +use crate::service::PrService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, required_text}; + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreatePrLabelParams { + pub name: String, + pub color: String, + pub description: Option, +} + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdatePrLabelParams { + pub name: Option, + pub color: Option, + pub description: Option, +} + +impl PrService { + pub async fn pr_labels( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + sqlx::query_as::<_, PrLabel>( + "SELECT id, repo_id, name, color, description, created_by, created_at, updated_at \ + FROM pr_label WHERE repo_id = $1 ORDER BY name ASC", + ) + .bind(repo.id) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn pr_create_label( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: CreatePrLabelParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + let name = required_text(params.name, "name")?; + let color = required_text(params.color, "color")?; + let now = chrono::Utc::now(); + sqlx::query_as::<_, PrLabel>( + "INSERT INTO pr_label (id, repo_id, name, color, description, created_by, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $7) \ + RETURNING id, repo_id, name, color, description, created_by, created_at, updated_at", + ) + .bind(Uuid::now_v7()).bind(repo.id).bind(&name).bind(&color) + .bind(params.description.as_deref()).bind(user_uid).bind(now) + .fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database) + } + + pub async fn pr_update_label( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + label_id: Uuid, + params: UpdatePrLabelParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + let current = sqlx::query_as::<_, PrLabel>( + "SELECT id, repo_id, name, color, description, created_by, created_at, updated_at \ + FROM pr_label WHERE id = $1 AND repo_id = $2", + ) + .bind(label_id) + .bind(repo.id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("label not found".into()))?; + + let name = params.name.unwrap_or(current.name); + let color = params.color.unwrap_or(current.color); + let description = super::util::merge_optional_text(params.description, current.description); + let now = chrono::Utc::now(); + sqlx::query_as::<_, PrLabel>( + "UPDATE pr_label SET name = $1, color = $2, description = $3, updated_at = $4 \ + WHERE id = $5 RETURNING id, repo_id, name, color, description, created_by, created_at, updated_at", + ) + .bind(&name).bind(&color).bind(&description).bind(now).bind(label_id) + .fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database) + } + + pub async fn pr_delete_label( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + label_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + let result = sqlx::query("DELETE FROM pr_label WHERE id = $1 AND repo_id = $2") + .bind(label_id) + .bind(repo.id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "label not found") + } + + pub async fn pr_assign_label( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + label_id: Uuid, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_editable(user_uid, &pr).await?; + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let rel = sqlx::query_as::<_, PrLabelRelation>( + "INSERT INTO pr_label_relation (id, pull_request_id, label_id, created_by, created_at) \ + VALUES ($1, $2, $3, $4, $5) \ + RETURNING id, pull_request_id, label_id, created_by, created_at", + ) + .bind(Uuid::now_v7()) + .bind(pr.id) + .bind(label_id) + .bind(user_uid) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(rel) + } + + pub async fn pr_unassign_label( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + label_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_editable(user_uid, &pr).await?; + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "DELETE FROM pr_label_relation WHERE pull_request_id = $1 AND label_id = $2", + ) + .bind(pr.id) + .bind(label_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "label not assigned")?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn pr_label_relations( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, PrLabelRelation>( + "SELECT id, pull_request_id, label_id, created_by, created_at \ + FROM pr_label_relation WHERE pull_request_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(pr.id).bind(limit).bind(offset) + .fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database) + } +} diff --git a/service/pr/merge_strategy.rs b/service/pr/merge_strategy.rs new file mode 100644 index 0000000..3bf3767 --- /dev/null +++ b/service/pr/merge_strategy.rs @@ -0,0 +1,132 @@ +use crate::error::AppError; +use crate::models::common::MergeStrategyKind; +use crate::models::prs::PrMergeStrategy; +use crate::service::PrService; +use crate::session::Session; + +use super::util::parse_enum; + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateMergeStrategyParams { + pub strategy: Option, + pub auto_merge: Option, + pub squash_title: Option, + pub squash_message: Option, + pub delete_source_branch: Option, + pub merge_when_checks_pass: Option, +} + +impl PrService { + pub async fn pr_merge_strategy( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + sqlx::query_as::<_, PrMergeStrategy>( + "SELECT pull_request_id, strategy, auto_merge, squash_title, squash_message, \ + delete_source_branch, merge_when_checks_pass, selected_by, created_at, updated_at \ + FROM pr_merge_strategy WHERE pull_request_id = $1", + ) + .bind(pr.id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("PR merge strategy not found".into())) + } + + pub async fn pr_update_merge_strategy( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + params: UpdateMergeStrategyParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_editable(user_uid, &pr).await?; + + let current = sqlx::query_as::<_, PrMergeStrategy>( + "SELECT pull_request_id, strategy, auto_merge, squash_title, squash_message, \ + delete_source_branch, merge_when_checks_pass, selected_by, created_at, updated_at \ + FROM pr_merge_strategy WHERE pull_request_id = $1", + ) + .bind(pr.id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let strategy = match params.strategy { + Some(ref v) => parse_enum( + Some(v.clone()), + current + .as_ref() + .map(|c| c.strategy) + .unwrap_or(MergeStrategyKind::Merge), + MergeStrategyKind::Unknown, + "strategy", + )?, + None => current + .as_ref() + .map(|c| c.strategy) + .unwrap_or(MergeStrategyKind::Merge), + }; + let auto_merge = params + .auto_merge + .or(current.as_ref().map(|c| c.auto_merge)) + .unwrap_or(false); + let squash_title = params + .squash_title + .or_else(|| current.as_ref().and_then(|c| c.squash_title.clone())); + let squash_message = params + .squash_message + .or_else(|| current.as_ref().and_then(|c| c.squash_message.clone())); + let delete_source_branch = params + .delete_source_branch + .or(current.as_ref().map(|c| c.delete_source_branch)) + .unwrap_or(false); + let merge_when_checks_pass = params + .merge_when_checks_pass + .or(current.as_ref().map(|c| c.merge_when_checks_pass)) + .unwrap_or(false); + let now = chrono::Utc::now(); + + if current.is_some() { + sqlx::query_as::<_, PrMergeStrategy>( + "UPDATE pr_merge_strategy SET strategy = $1, auto_merge = $2, squash_title = $3, \ + squash_message = $4, delete_source_branch = $5, merge_when_checks_pass = $6, \ + selected_by = $7, updated_at = $8 WHERE pull_request_id = $9 \ + RETURNING pull_request_id, strategy, auto_merge, squash_title, squash_message, \ + delete_source_branch, merge_when_checks_pass, selected_by, created_at, updated_at", + ) + .bind(strategy) + .bind(auto_merge) + .bind(squash_title.as_deref()) + .bind(squash_message.as_deref()) + .bind(delete_source_branch) + .bind(merge_when_checks_pass) + .bind(user_uid) + .bind(now) + .bind(pr.id) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database) + } else { + sqlx::query_as::<_, PrMergeStrategy>( + "INSERT INTO pr_merge_strategy (pull_request_id, strategy, auto_merge, squash_title, \ + squash_message, delete_source_branch, merge_when_checks_pass, selected_by, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) \ + RETURNING pull_request_id, strategy, auto_merge, squash_title, squash_message, \ + delete_source_branch, merge_when_checks_pass, selected_by, created_at, updated_at", + ) + .bind(pr.id).bind(strategy).bind(auto_merge).bind(squash_title.as_deref()).bind(squash_message.as_deref()) + .bind(delete_source_branch).bind(merge_when_checks_pass).bind(user_uid).bind(now) + .fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database) + } + } +} diff --git a/service/pr/mod.rs b/service/pr/mod.rs new file mode 100644 index 0000000..acbfe5f --- /dev/null +++ b/service/pr/mod.rs @@ -0,0 +1,13 @@ +pub mod assignees; +pub mod check_runs; +pub mod commits; +pub mod core; +pub mod events; +pub mod files; +pub mod labels; +pub mod merge_strategy; +pub mod reactions; +pub mod reviews; +pub mod status; +pub mod subscriptions; +pub mod util; diff --git a/service/pr/reactions.rs b/service/pr/reactions.rs new file mode 100644 index 0000000..5c67408 --- /dev/null +++ b/service/pr/reactions.rs @@ -0,0 +1,96 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::TargetType; +use crate::models::prs::PrReaction; +use crate::service::PrService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, required_text}; + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateReactionParams { + pub content: String, + pub target_type: Option, + pub target_id: Option, +} + +impl PrService { + pub async fn pr_reactions( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, PrReaction>( + "SELECT id, pull_request_id, user_id, content, target_type, target_id, created_at \ + FROM pr_reaction WHERE pull_request_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(pr.id).bind(limit).bind(offset) + .fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database) + } + + pub async fn pr_add_reaction( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + params: CreateReactionParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + let content = required_text(params.content, "content")?; + let target_type = params + .target_type + .as_deref() + .and_then(|s| s.parse::().ok()) + .unwrap_or(TargetType::PullRequest); + if target_type == TargetType::Unknown { + return Err(AppError::BadRequest("invalid target_type".into())); + } + let now = chrono::Utc::now(); + sqlx::query_as::<_, PrReaction>( + "INSERT INTO pr_reaction (id, pull_request_id, user_id, content, target_type, target_id, created_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7) \ + RETURNING id, pull_request_id, user_id, content, target_type, target_id, created_at", + ) + .bind(Uuid::now_v7()).bind(pr.id).bind(user_uid).bind(&content) + .bind(target_type).bind(params.target_id).bind(now) + .fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database) + } + + pub async fn pr_remove_reaction( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + reaction_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + let result = sqlx::query( + "DELETE FROM pr_reaction WHERE id = $1 AND pull_request_id = $2 AND user_id = $3", + ) + .bind(reaction_id) + .bind(pr.id) + .bind(user_uid) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + ensure_affected( + result.rows_affected(), + "reaction not found or not authored by you", + ) + } +} diff --git a/service/pr/reviews.rs b/service/pr/reviews.rs new file mode 100644 index 0000000..d845955 --- /dev/null +++ b/service/pr/reviews.rs @@ -0,0 +1,431 @@ +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::prs::{PrReview, PrReviewComment}; +use crate::service::PrService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, required_text}; + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct CreateReviewParams { + pub body: Option, + pub state: Option, + pub commit_sha: Option, + pub comments: Option>, +} + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct ReviewCommentParams { + pub path: String, + pub body: String, + pub line: Option, + pub start_line: Option, + pub diff_hunk: Option, +} + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct SubmitReviewParams { + pub body: Option, + pub state: String, +} + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct DismissReviewParams { + pub reason: String, +} + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct AddReplyParams { + pub body: String, +} + +impl PrService { + pub async fn pr_list_reviews( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, PrReview>( + "SELECT id, pull_request_id, author_id, state, body, commit_sha, \ + submitted_at, dismissed_at, dismissed_by, dismiss_reason, created_at, updated_at \ + FROM pr_review WHERE pull_request_id = $1 \ + ORDER BY created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(pr.id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn pr_create_review( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + params: CreateReviewParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + + let state = params.state.as_deref().unwrap_or("pending"); + if !["pending", "approved", "changes_requested", "commented"].contains(&state) { + return Err(AppError::BadRequest("invalid review state".into())); + } + if matches!(state, "approved" | "changes_requested") { + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + if state == "approved" && pr.author_id == user_uid { + return Err(AppError::BadRequest( + "PR authors cannot approve their own pull requests".into(), + )); + } + } + + let now = Utc::now(); + let review_id = Uuid::now_v7(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let review = sqlx::query_as::<_, PrReview>( + "INSERT INTO pr_review (id, pull_request_id, author_id, state, body, commit_sha, \ + submitted_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \ + RETURNING id, pull_request_id, author_id, state, body, commit_sha, \ + submitted_at, dismissed_at, dismissed_by, dismiss_reason, created_at, updated_at", + ) + .bind(review_id) + .bind(pr.id) + .bind(user_uid) + .bind(state) + .bind(params.body.as_deref()) + .bind( + params + .commit_sha + .as_deref() + .or(Some(pr.head_commit_sha.as_str())), + ) + .bind(if state != "pending" { Some(now) } else { None }) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + if let Some(comments) = ¶ms.comments { + for c in comments { + let path = required_text(c.path.clone(), "path")?; + let body = required_text(c.body.clone(), "body")?; + sqlx::query( + "INSERT INTO pr_review_comment (id, review_id, pull_request_id, author_id, body, path, \ + line, original_line, start_line, original_start_line, diff_hunk, in_reply_to_id, \ + edited_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NULL, NULL, $12, $12)", + ) + .bind(Uuid::now_v7()).bind(review_id).bind(pr.id).bind(user_uid) + .bind(&body).bind(&path) + .bind(c.line).bind(c.line) + .bind(c.start_line).bind(c.start_line) + .bind(c.diff_hunk.as_deref()) + .bind(now) + .execute(&mut *txn).await.map_err(AppError::Database)?; + } + } + + if matches!(state, "approved" | "changes_requested") { + sqlx::query( + "UPDATE pr_status SET approvals_count = (SELECT COUNT(*) FROM pr_review r \ + JOIN pull_request pr ON pr.id = r.pull_request_id \ + WHERE r.pull_request_id = $1 AND r.state = 'approved' AND r.dismissed_at IS NULL \ + AND r.submitted_at IS NOT NULL AND r.author_id <> pr.author_id), \ + updated_at = $2 WHERE pull_request_id = $1", + ) + .bind(pr.id) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(review) + } + + pub async fn pr_submit_review( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + review_id: Uuid, + params: SubmitReviewParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + + let state = params.state.as_str(); + if !["approved", "changes_requested", "commented"].contains(&state) { + return Err(AppError::BadRequest("invalid review state".into())); + } + + if matches!(state, "approved" | "changes_requested") { + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + if state == "approved" && pr.author_id == user_uid { + return Err(AppError::BadRequest( + "PR authors cannot approve their own pull requests".into(), + )); + } + } + + let now = Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let review = sqlx::query_as::<_, PrReview>( + "UPDATE pr_review SET state = $1, body = COALESCE($2, body), submitted_at = $3, updated_at = $3 \ + WHERE id = $4 AND pull_request_id = $5 AND author_id = $6 AND submitted_at IS NULL AND dismissed_at IS NULL \ + RETURNING id, pull_request_id, author_id, state, body, commit_sha, \ + submitted_at, dismissed_at, dismissed_by, dismiss_reason, created_at, updated_at", + ) + .bind(state).bind(params.body.as_deref()).bind(now) + .bind(review_id).bind(pr.id).bind(user_uid) + .fetch_optional(&mut *txn).await.map_err(AppError::Database)? + .ok_or(AppError::NotFound("review not found or already submitted".into()))?; + + if state == "approved" || state == "changes_requested" { + sqlx::query( + "UPDATE pr_status SET approvals_count = (SELECT COUNT(*) FROM pr_review r \ + JOIN pull_request pr ON pr.id = r.pull_request_id \ + WHERE r.pull_request_id = $1 AND r.state = 'approved' AND r.dismissed_at IS NULL \ + AND r.submitted_at IS NOT NULL AND r.author_id <> pr.author_id), \ + updated_at = $2 WHERE pull_request_id = $1", + ) + .bind(pr.id) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(review) + } + + pub async fn pr_dismiss_review( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + review_id: Uuid, + params: DismissReviewParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let reason = required_text(params.reason, "reason")?; + let now = Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let review = sqlx::query_as::<_, PrReview>( + "UPDATE pr_review SET dismissed_at = $1, dismissed_by = $2, dismiss_reason = $3, updated_at = $1 \ + WHERE id = $4 AND pull_request_id = $5 AND submitted_at IS NOT NULL AND dismissed_at IS NULL \ + RETURNING id, pull_request_id, author_id, state, body, commit_sha, \ + submitted_at, dismissed_at, dismissed_by, dismiss_reason, created_at, updated_at", + ) + .bind(now).bind(user_uid).bind(&reason) + .bind(review_id).bind(pr.id) + .fetch_optional(&mut *txn).await.map_err(AppError::Database)? + .ok_or(AppError::NotFound("review not found or not submitted".into()))?; + + sqlx::query( + "UPDATE pr_status SET approvals_count = (SELECT COUNT(*) FROM pr_review \ + WHERE pull_request_id = $1 AND state = 'approved' AND dismissed_at IS NULL), \ + updated_at = $2 WHERE pull_request_id = $1", + ) + .bind(pr.id) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(review) + } + + #[allow(clippy::too_many_arguments)] + pub async fn pr_review_comments( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + review_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, PrReviewComment>( + "SELECT id, review_id, pull_request_id, author_id, body, path, line, original_line, \ + start_line, original_start_line, diff_hunk, in_reply_to_id, edited_at, created_at, updated_at \ + FROM pr_review_comment WHERE review_id = $1 AND pull_request_id = $2 ORDER BY created_at ASC LIMIT $3 OFFSET $4", + ) + .bind(review_id).bind(pr.id).bind(limit).bind(offset) + .fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database) + } + + pub async fn pr_add_review_reply( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + comment_id: Uuid, + params: AddReplyParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + + let body = required_text(params.body, "body")?; + let parent = sqlx::query_as::<_, PrReviewComment>( + "SELECT id, review_id, pull_request_id, author_id, body, path, line, original_line, \ + start_line, original_start_line, diff_hunk, in_reply_to_id, edited_at, created_at, updated_at \ + FROM pr_review_comment WHERE id = $1 AND pull_request_id = $2", + ) + .bind(comment_id).bind(pr.id) + .fetch_optional(self.ctx.db.reader()).await.map_err(AppError::Database)? + .ok_or(AppError::NotFound("comment not found".into()))?; + + let now = Utc::now(); + sqlx::query_as::<_, PrReviewComment>( + "INSERT INTO pr_review_comment (id, review_id, pull_request_id, author_id, body, path, \ + line, original_line, start_line, original_start_line, diff_hunk, in_reply_to_id, \ + edited_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL, $13, $13) \ + RETURNING id, review_id, pull_request_id, author_id, body, path, line, original_line, \ + start_line, original_start_line, diff_hunk, in_reply_to_id, edited_at, created_at, updated_at", + ) + .bind(Uuid::now_v7()).bind(parent.review_id).bind(pr.id).bind(user_uid) + .bind(&body).bind(&parent.path) + .bind(parent.line).bind(parent.original_line) + .bind(parent.start_line).bind(parent.original_start_line) + .bind(parent.diff_hunk.as_deref()) + .bind(comment_id).bind(now) + .fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database) + } + + pub async fn pr_update_review_comment( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + comment_id: Uuid, + params: AddReplyParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + let body = required_text(params.body, "body")?; + let now = Utc::now(); + sqlx::query_as::<_, PrReviewComment>( + "UPDATE pr_review_comment SET body = $1, edited_at = $2, updated_at = $2 \ + WHERE id = $3 AND pull_request_id = $4 AND author_id = $5 \ + RETURNING id, review_id, pull_request_id, author_id, body, path, line, original_line, \ + start_line, original_start_line, diff_hunk, in_reply_to_id, edited_at, created_at, updated_at", + ) + .bind(&body).bind(now).bind(comment_id).bind(pr.id).bind(user_uid) + .fetch_optional(self.ctx.db.writer()).await.map_err(AppError::Database)? + .ok_or(AppError::NotFound("comment not found or not authored by you".into())) + } + + pub async fn pr_delete_review_comment( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + comment_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + + let comment = sqlx::query_as::<_, PrReviewComment>( + "SELECT id, review_id, pull_request_id, author_id, body, path, line, original_line, \ + start_line, original_start_line, diff_hunk, in_reply_to_id, edited_at, created_at, updated_at \ + FROM pr_review_comment WHERE id = $1 AND pull_request_id = $2", + ) + .bind(comment_id).bind(pr.id) + .fetch_optional(self.ctx.db.reader()).await.map_err(AppError::Database)? + .ok_or(AppError::NotFound("comment not found".into()))?; + + if comment.author_id != user_uid { + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + } + + let result = sqlx::query("DELETE FROM pr_review_comment WHERE id = $1") + .bind(comment_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "comment not found") + } +} diff --git a/service/pr/status.rs b/service/pr/status.rs new file mode 100644 index 0000000..d333903 --- /dev/null +++ b/service/pr/status.rs @@ -0,0 +1,29 @@ +use crate::error::AppError; +use crate::models::prs::PrStatus; +use crate::service::PrService; +use crate::session::Session; + +impl PrService { + pub async fn pr_status( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + sqlx::query_as::<_, PrStatus>( + "SELECT pull_request_id, head_commit_sha, checks_state, mergeable_state, conflicts, \ + approvals_count, requested_reviews_count, changed_files_count, additions_count, \ + deletions_count, updated_at \ + FROM pr_status WHERE pull_request_id = $1", + ) + .bind(pr.id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("PR status not found".into())) + } +} diff --git a/service/pr/subscriptions.rs b/service/pr/subscriptions.rs new file mode 100644 index 0000000..775a10f --- /dev/null +++ b/service/pr/subscriptions.rs @@ -0,0 +1,123 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::prs::PrSubscription; +use crate::service::PrService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected}; + +impl PrService { + pub async fn pr_subscriptions( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, PrSubscription>( + "SELECT id, pull_request_id, user_id, reason, muted, created_at, updated_at \ + FROM pr_subscription WHERE pull_request_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(pr.id).bind(limit).bind(offset) + .fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database) + } + + pub async fn pr_subscribe( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let sub = sqlx::query_as::<_, PrSubscription>( + "INSERT INTO pr_subscription (id, pull_request_id, user_id, reason, muted, created_at, updated_at) \ + VALUES ($1, $2, $3, 'manual', false, $4, $4) ON CONFLICT (pull_request_id, user_id) DO NOTHING \ + RETURNING id, pull_request_id, user_id, reason, muted, created_at, updated_at", + ) + .bind(Uuid::now_v7()).bind(pr.id).bind(user_uid).bind(now) + .fetch_optional(&mut *txn).await.map_err(AppError::Database)? + .ok_or(AppError::Conflict("already subscribed".into()))?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(sub) + } + + pub async fn pr_unsubscribe( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = + sqlx::query("DELETE FROM pr_subscription WHERE pull_request_id = $1 AND user_id = $2") + .bind(pr.id) + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "not subscribed")?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn pr_mute( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + muted: bool, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + let result = sqlx::query( + "UPDATE pr_subscription SET muted = $1, updated_at = $2 WHERE pull_request_id = $3 AND user_id = $4", + ) + .bind(muted).bind(chrono::Utc::now()).bind(pr.id).bind(user_uid) + .execute(self.ctx.db.writer()).await.map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "not subscribed") + } +} diff --git a/service/pr/util.rs b/service/pr/util.rs new file mode 100644 index 0000000..a83877c --- /dev/null +++ b/service/pr/util.rs @@ -0,0 +1,3 @@ +pub use crate::service::util::{ + clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text, role_level, +}; diff --git a/service/repo/branches.rs b/service/repo/branches.rs new file mode 100644 index 0000000..e1cc840 --- /dev/null +++ b/service/repo/branches.rs @@ -0,0 +1,278 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::repos::RepoBranch; +use crate::service::RepoService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, required_text}; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateBranchParams { + pub name: String, + pub commit_sha: String, +} + +impl RepoService { + pub async fn repo_branches( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_readable(user_uid, &repo).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, RepoBranch>( + "SELECT id, repo_id, name, commit_sha, protected, default_branch, created_by, last_push_id, last_push_at, created_at, updated_at FROM repo_branch WHERE repo_id = $1 ORDER BY default_branch DESC, name ASC LIMIT $2 OFFSET $3", + ) + .bind(repo_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn repo_create_branch( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: CreateBranchParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + let name = required_text(params.name, "name")?; + + let existing = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM repo_branch WHERE repo_id = $1 AND name = $2)", + ) + .bind(repo_id) + .bind(&name) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if existing { + return Err(AppError::Conflict("branch already exists".into())); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let branch = sqlx::query_as::<_, RepoBranch>( + "INSERT INTO repo_branch (id, repo_id, name, commit_sha, protected, default_branch, created_by, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, false, false, $5, $6, $6) RETURNING id, repo_id, name, commit_sha, protected, default_branch, created_by, last_push_id, last_push_at, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(repo_id) + .bind(&name) + .bind(¶ms.commit_sha) + .bind(user_uid) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE repo_stats SET branches_count = branches_count + 1, updated_at = $1 WHERE repo_id = $2", + ) + .bind(now) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(branch) + } + + pub async fn repo_set_default_branch( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + branch_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let branch = sqlx::query_as::<_, RepoBranch>( + "SELECT id, repo_id, name, commit_sha, protected, default_branch, created_by, last_push_id, last_push_at, created_at, updated_at FROM repo_branch WHERE id = $1 AND repo_id = $2", + ) + .bind(branch_id) + .bind(repo_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("branch not found".into()))?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query("UPDATE repo_branch SET default_branch = false, updated_at = $1 WHERE repo_id = $2 AND default_branch = true") + .bind(now) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query("UPDATE repo_branch SET default_branch = true, updated_at = $1 WHERE id = $2") + .bind(now) + .bind(branch_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query("UPDATE repo SET default_branch = $1, updated_at = $2 WHERE id = $3 AND deleted_at IS NULL") + .bind(&branch.name) + .bind(now) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn repo_set_branch_protection( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + branch_id: Uuid, + protected: bool, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE repo_branch SET protected = $1, updated_at = $2 WHERE id = $3 AND repo_id = $4", + ) + .bind(protected) + .bind(now) + .bind(branch_id) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "branch not found")?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn repo_delete_branch( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + branch_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let is_default = sqlx::query_scalar::<_, bool>( + "SELECT default_branch FROM repo_branch WHERE id = $1 AND repo_id = $2", + ) + .bind(branch_id) + .bind(repo_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("branch not found".into()))?; + + if is_default { + return Err(AppError::BadRequest("cannot delete default branch".into())); + } + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query("DELETE FROM repo_branch WHERE id = $1 AND repo_id = $2") + .bind(branch_id) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "branch not found")?; + + sqlx::query( + "UPDATE repo_stats SET branches_count = GREATEST(branches_count - 1, 0), updated_at = $1 WHERE repo_id = $2", + ) + .bind(chrono::Utc::now()) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } +} diff --git a/service/repo/commit_status.rs b/service/repo/commit_status.rs new file mode 100644 index 0000000..51908ba --- /dev/null +++ b/service/repo/commit_status.rs @@ -0,0 +1,241 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::{Role, State}; +use crate::models::repos::{RepoCommitComment, RepoCommitStatus}; +use crate::service::RepoService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, required_text}; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateCommitStatusParams { + pub push_commit_id: Uuid, + pub latest_commit_sha: String, + pub context: String, + pub state: String, + pub target_url: Option, + pub description: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateCommitCommentParams { + pub push_commit_id: Uuid, + pub commit_sha: String, + pub body: String, + pub path: Option, + pub line: Option, +} + +impl RepoService { + pub async fn repo_commit_statuses( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + push_commit_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_readable(user_uid, &repo).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, RepoCommitStatus>( + "SELECT id, repo_id, push_commit_id, latest_commit_sha, context, state, target_url, description, reported_by, reported_at, created_at, updated_at FROM repo_commit_status WHERE repo_id = $1 AND push_commit_id = $2 ORDER BY created_at DESC LIMIT $3 OFFSET $4", + ) + .bind(repo_id) + .bind(push_commit_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn repo_create_commit_status( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: CreateCommitStatusParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + let context = required_text(params.context, "context")?; + let state = params + .state + .trim() + .parse::() + .map_err(|_| AppError::BadRequest("invalid state".into()))?; + if state == State::Unknown { + return Err(AppError::BadRequest("invalid state".into())); + } + let latest_commit_sha = required_text(params.latest_commit_sha, "latest_commit_sha")?; + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, RepoCommitStatus>( + "INSERT INTO repo_commit_status (id, repo_id, push_commit_id, latest_commit_sha, context, \ + state, target_url, description, reported_by, reported_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10, $10) RETURNING id, repo_id, push_commit_id, latest_commit_sha, context, state, target_url, description, reported_by, reported_at, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(repo_id) + .bind(params.push_commit_id) + .bind(&latest_commit_sha) + .bind(&context) + .bind(state) + .bind(¶ms.target_url) + .bind(¶ms.description) + .bind(user_uid) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn repo_commit_comments( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + push_commit_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_readable(user_uid, &repo).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, RepoCommitComment>( + "SELECT id, repo_id, push_commit_id, commit_sha, author_id, body, path, line, resolved, resolved_by, resolved_at, created_at, updated_at, deleted_at FROM repo_commit_comment WHERE repo_id = $1 AND push_commit_id = $2 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT $3 OFFSET $4", + ) + .bind(repo_id) + .bind(push_commit_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn repo_create_commit_comment( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: CreateCommitCommentParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + let body = required_text(params.body, "body")?; + let commit_sha = required_text(params.commit_sha, "commit_sha")?; + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, RepoCommitComment>( + "INSERT INTO repo_commit_comment (id, repo_id, push_commit_id, commit_sha, author_id, body, \ + path, line, resolved, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, $9, $9) RETURNING id, repo_id, push_commit_id, commit_sha, author_id, body, path, line, resolved, resolved_by, resolved_at, created_at, updated_at, deleted_at", + ) + .bind(Uuid::now_v7()) + .bind(repo_id) + .bind(params.push_commit_id) + .bind(&commit_sha) + .bind(user_uid) + .bind(&body) + .bind(¶ms.path) + .bind(params.line) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn repo_resolve_commit_comment( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + comment_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE repo_commit_comment SET resolved = true, resolved_by = $1, resolved_at = $2, updated_at = $2 \ + WHERE id = $3 AND repo_id = $4 AND deleted_at IS NULL AND resolved = false", + ) + .bind(user_uid) + .bind(now) + .bind(comment_id) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + super::util::ensure_affected( + result.rows_affected(), + "comment not found or already resolved", + )?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } +} diff --git a/service/repo/core.rs b/service/repo/core.rs new file mode 100644 index 0000000..46a5fac --- /dev/null +++ b/service/repo/core.rs @@ -0,0 +1,789 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::{GitService, Role, Visibility}; +use crate::models::repos::Repo; +use crate::service::RepoService; +use crate::session::Session; + +use super::util::{ + clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text, +}; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateRepoParams { + pub name: String, + pub description: Option, + pub visibility: Option, + pub default_branch: Option, + pub git_service: Option, + pub storage_node_ids: Option>, + pub storage_path: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateRepoParams { + pub name: Option, + pub description: Option, + pub visibility: Option, + pub default_branch: Option, +} + +fn validate_storage_path(path: &str) -> Result { + let path = path.trim().trim_matches('/'); + if path.is_empty() + || path.contains("..") + || path.split('/').any(|part| part.is_empty() || part == ".") + { + return Err(AppError::BadRequest("storage_path is invalid".into())); + } + Ok(path.to_string()) +} + +impl RepoService { + pub async fn repo_list( + &self, + ctx: &Session, + wk_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.resolve_workspace(wk_name).await?; + self.ensure_workspace_readable(user_uid, &ws).await?; + let workspace_id = ws.id; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, Repo>( + "SELECT id, workspace_id, owner_id, name, description, default_branch, visibility, status, is_fork, forked_from_repo_id, storage_node_ids, primary_storage_node_id, storage_path, git_service, archived_at, created_at, updated_at, deleted_at \ + FROM repo WHERE workspace_id = $1 AND deleted_at IS NULL AND (owner_id = $2 OR visibility = 'public' OR id IN (SELECT repo_id FROM repo_member WHERE user_id = $2 AND status = 'active') OR (visibility = 'internal' AND EXISTS (SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active'))) \ + ORDER BY created_at DESC LIMIT $3 OFFSET $4", + ) + .bind(workspace_id) + .bind(user_uid) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn repo_get( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + Ok(repo) + } + + pub async fn repo_create( + &self, + ctx: &Session, + wk_name: &str, + params: CreateRepoParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.resolve_workspace(wk_name).await?; + let workspace_id = ws.id; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Member) + .await?; + + let name = required_text(params.name, "name")?; + let visibility = match params.visibility { + Some(ref v) => parse_enum( + Some(v.clone()), + Visibility::Private, + Visibility::Unknown, + "visibility", + )?, + None => { + let settings_visibility: String = sqlx::query_scalar( + "SELECT default_repo_visibility FROM workspace_settings WHERE workspace_id = $1", + ) + .bind(workspace_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + settings_visibility.parse().unwrap_or(Visibility::Private) + } + }; + if visibility == Visibility::Public { + let allow_public_repos: bool = sqlx::query_scalar( + "SELECT allow_public_repos FROM workspace_settings WHERE workspace_id = $1", + ) + .bind(workspace_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + if !allow_public_repos { + return Err(AppError::BadRequest( + "public repositories are disabled for this workspace".into(), + )); + } + } + + let default_branch = required_text( + params.default_branch.unwrap_or_else(|| "main".to_string()), + "default_branch", + )?; + let git_service = match params.git_service { + Some(ref v) => parse_enum( + Some(v.clone()), + GitService::Local, + GitService::Unknown, + "git_service", + )?, + None => GitService::Local, + }; + + let available_storage_nodes: std::collections::HashSet = + self.ctx.registry.git_node_ids().into_iter().collect(); + if available_storage_nodes.is_empty() { + return Err(AppError::Config("no git storage nodes configured".into())); + } + let storage_node_ids = params.storage_node_ids.unwrap_or_else(|| { + available_storage_nodes + .iter() + .copied() + .collect::>() + }); + if storage_node_ids.is_empty() + || storage_node_ids + .iter() + .any(|node_id| !available_storage_nodes.contains(node_id)) + { + return Err(AppError::BadRequest("invalid storage_node_ids".into())); + } + let primary_storage_node_id = storage_node_ids[0]; + + let now = chrono::Utc::now(); + let repo_id = Uuid::now_v7(); + let storage_path = match params.storage_path { + Some(path) if !path.trim().is_empty() => validate_storage_path(&path)?, + Some(_) => return Err(AppError::BadRequest("storage_path is invalid".into())), + None => format!("repos/{}/{}", workspace_id, repo_id), + }; + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let repo = sqlx::query_as::<_, Repo>( + "INSERT INTO repo (id, workspace_id, owner_id, name, description, default_branch, \ + visibility, status, is_fork, forked_from_repo_id, storage_node_ids, \ + primary_storage_node_id, storage_path, git_service, archived_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', false, NULL, $8, $9, $10, $11, NULL, $12, $12) \ + RETURNING id, workspace_id, owner_id, name, description, default_branch, visibility, status, is_fork, forked_from_repo_id, storage_node_ids, primary_storage_node_id, storage_path, git_service, archived_at, created_at, updated_at, deleted_at", + ) + .bind(repo_id) + .bind(workspace_id) + .bind(user_uid) + .bind(&name) + .bind(params.description.as_deref()) + .bind(&default_branch) + .bind(visibility) + .bind(&storage_node_ids) + .bind(primary_storage_node_id) + .bind(&storage_path) + .bind(git_service) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO repo_member (id, repo_id, user_id, role, status, joined_at, created_at, updated_at) \ + VALUES ($1, $2, $3, 'owner', 'active', $4, $4, $4)", + ) + .bind(Uuid::now_v7()) + .bind(repo_id) + .bind(user_uid) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO repo_stats (repo_id, stars_count, watchers_count, forks_count, branches_count, \ + tags_count, commits_count, releases_count, open_issues_count, open_pull_requests_count, \ + size_bytes, updated_at) \ + VALUES ($1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, $2)", + ) + .bind(repo_id) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO repo_branch (id, repo_id, name, commit_sha, protected, default_branch, created_by, created_at, updated_at) \ + VALUES ($1, $2, $3, '', false, true, $4, $5, $5)", + ) + .bind(Uuid::now_v7()) + .bind(repo_id) + .bind(&default_branch) + .bind(user_uid) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE workspace_stats SET repos_count = repos_count + 1, updated_at = $1 WHERE workspace_id = $2", + ) + .bind(now) + .bind(workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + + // Init git repo on primary node (nodes auto-sync). + if let Some(mut client) = self.ctx.registry.get_git_client(&primary_storage_node_id) { + let req = tonic::Request::new(crate::pb::repo::InitRepositoryRequest { + repository: Some(crate::pb::repo::RepositoryHeader { + storage_name: ws.name.clone(), + relative_path: format!("{}.git", name), + storage_path: storage_path.clone(), + }), + bare: true, + object_format: crate::pb::repo::ObjectFormat::Sha1 as i32, + initial_branch: default_branch.clone(), + }); + if let Err(err) = client.repository.init_repository(req).await { + tracing::error!(repo_id = %repo_id, error = %err, "Failed to init git repo"); + let _ = sqlx::query( + "UPDATE repo SET status = 'deleted', deleted_at = $1 WHERE id = $2", + ) + .bind(chrono::Utc::now()) + .bind(repo_id) + .execute(self.ctx.db.writer()) + .await; + let _ = sqlx::query("UPDATE workspace_stats SET repos_count = GREATEST(repos_count - 1, 0), updated_at = $1 WHERE workspace_id = $2") + .bind(chrono::Utc::now()).bind(workspace_id).execute(self.ctx.db.writer()).await; + return Err(AppError::InternalServerError(err.to_string())); + } + } + + Ok(repo) + } + + pub async fn repo_update( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: UpdateRepoParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let name = + merge_optional_text(params.name, Some(repo.name.clone())).unwrap_or(repo.name.clone()); + let description = merge_optional_text(params.description, repo.description); + let visibility = parse_enum( + params.visibility, + repo.visibility, + Visibility::Unknown, + "visibility", + )?; + if visibility == Visibility::Public { + let allow_public_repos: bool = sqlx::query_scalar( + "SELECT allow_public_repos FROM workspace_settings WHERE workspace_id = $1", + ) + .bind(repo.workspace_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + if !allow_public_repos { + return Err(AppError::BadRequest( + "public repositories are disabled for this workspace".into(), + )); + } + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE repo SET name = $1, description = $2, visibility = $3, updated_at = $4 \ + WHERE id = $5 AND deleted_at IS NULL", + ) + .bind(&name) + .bind(&description) + .bind(visibility) + .bind(now) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + if let Some(ref new_default) = params.default_branch + && new_default != &repo.default_branch + { + // Check if the branch exists + let branch_exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM repo_branch WHERE repo_id = $1 AND name = $2)", + ) + .bind(repo_id) + .bind(new_default) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + if !branch_exists { + return Err(AppError::BadRequest(format!( + "Branch '{}' does not exist", + new_default + ))); + } + + sqlx::query( + "UPDATE repo_branch SET default_branch = false, updated_at = $1 WHERE repo_id = $2 AND default_branch = true", + ) + .bind(now) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE repo_branch SET default_branch = true, updated_at = $1 WHERE repo_id = $2 AND name = $3", + ) + .bind(now) + .bind(repo_id) + .bind(new_default) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query("UPDATE repo SET default_branch = $1, updated_at = $2 WHERE id = $3") + .bind(new_default) + .bind(now) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } + + let result = sqlx::query_as::<_, Repo>( + "SELECT id, workspace_id, owner_id, name, description, default_branch, visibility, status, is_fork, forked_from_repo_id, storage_node_ids, primary_storage_node_id, storage_path, git_service, archived_at, created_at, updated_at, deleted_at \ + FROM repo WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(repo_id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn repo_archive( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Owner) + .await?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE repo SET status = 'archived', archived_at = $1, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL AND status <> 'archived'", + ) + .bind(now) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "repo not found or already archived")?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn repo_unarchive( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Owner) + .await?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE repo SET status = 'active', archived_at = NULL, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL AND status = 'archived'", + ) + .bind(now) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "repo not found or not archived")?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn repo_delete( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Owner) + .await?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE repo SET deleted_at = $1, status = 'deleted', updated_at = $1 WHERE id = $2 AND deleted_at IS NULL", + ) + .bind(now) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "repo not found")?; + + sqlx::query( + "UPDATE workspace_stats SET repos_count = GREATEST(repos_count - 1, 0), updated_at = $1 WHERE workspace_id = $2", + ) + .bind(now) + .bind(repo.workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn repo_transfer_owner( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + new_owner_id: Uuid, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Owner) + .await?; + + if new_owner_id == repo.owner_id { + return Err(AppError::BadRequest( + "new owner must be different from current owner".into(), + )); + } + let is_workspace_member = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')", + ) + .bind(repo.workspace_id) + .bind(new_owner_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + let is_member = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM repo_member WHERE repo_id = $1 AND user_id = $2 AND status = 'active')", + ) + .bind(repo_id) + .bind(new_owner_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if !is_workspace_member || !is_member { + return Err(AppError::BadRequest( + "new owner must be an active workspace and repo member".into(), + )); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query("UPDATE repo_member SET role = 'owner', updated_at = $1 WHERE repo_id = $2 AND user_id = $3") + .bind(now) + .bind(repo_id) + .bind(new_owner_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query("UPDATE repo_member SET role = 'admin', updated_at = $1 WHERE repo_id = $2 AND user_id = $3") + .bind(now) + .bind(repo_id) + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, Repo>( + "UPDATE repo SET owner_id = $1, updated_at = $2 WHERE id = $3 AND deleted_at IS NULL \ + RETURNING id, workspace_id, owner_id, name, description, default_branch, visibility, status, is_fork, forked_from_repo_id, storage_node_ids, primary_storage_node_id, storage_path, git_service, archived_at, created_at, updated_at, deleted_at", + ) + .bind(new_owner_id) + .bind(now) + .bind(repo_id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub(crate) async fn resolve_workspace( + &self, + wk_name: &str, + ) -> Result { + crate::models::workspaces::Workspace::find_by_name(self.ctx.db.reader(), wk_name) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("workspace not found".into())) + } + + pub(crate) async fn resolve_repo( + &self, + wk_name: &str, + repo_name: &str, + ) -> Result { + let ws = self.resolve_workspace(wk_name).await?; + Repo::find_by_name(self.ctx.db.reader(), ws.id, repo_name) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("repo not found".into())) + } + + pub(crate) async fn find_repo_by_id(&self, repo_id: Uuid) -> Result { + sqlx::query_as::<_, Repo>( + "SELECT id, workspace_id, owner_id, name, description, default_branch, visibility, status, is_fork, forked_from_repo_id, storage_node_ids, primary_storage_node_id, storage_path, git_service, archived_at, created_at, updated_at, deleted_at FROM repo WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(repo_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("repo not found".into())) + } + + pub async fn repo_user_role( + &self, + user_uid: Uuid, + repo_id: Uuid, + ) -> Result, AppError> { + let role_str: Option = sqlx::query_scalar( + "SELECT role FROM repo_member WHERE repo_id = $1 AND user_id = $2 AND status = 'active'", + ) + .bind(repo_id) + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + match role_str { + Some(r) => Ok(Some(r.parse().unwrap_or(Role::Unknown))), + None => { + let repo = self.find_repo_by_id(repo_id).await?; + if repo.owner_id == user_uid { + return Ok(Some(Role::Owner)); + } + Ok(None) + } + } + } + + pub async fn ensure_repo_readable(&self, user_uid: Uuid, repo: &Repo) -> Result<(), AppError> { + if repo.owner_id == user_uid { + return Ok(()); + } + let is_workspace_member = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')", + ) + .bind(repo.workspace_id) + .bind(user_uid) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + let is_member = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM repo_member WHERE repo_id = $1 AND user_id = $2 AND status = 'active')", + ) + .bind(repo.id) + .bind(user_uid) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + match repo.visibility { + Visibility::Public => Ok(()), + Visibility::Internal if is_workspace_member => Ok(()), + Visibility::Private if is_workspace_member && is_member => Ok(()), + _ => Err(AppError::Unauthorized), + } + } + + pub async fn ensure_repo_role_at_least( + &self, + user_uid: Uuid, + repo: &Repo, + min_role: Role, + ) -> Result { + if repo.owner_id == user_uid { + return Ok(Role::Owner); + } + let is_workspace_member = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')", + ) + .bind(repo.workspace_id) + .bind(user_uid) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + if !is_workspace_member { + return Err(AppError::Unauthorized); + } + + let role_str: Option = sqlx::query_scalar( + "SELECT role FROM repo_member WHERE repo_id = $1 AND user_id = $2 AND status = 'active'", + ) + .bind(repo.id) + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let role = role_str + .and_then(|r| r.parse::().ok()) + .unwrap_or(Role::Unknown); + + if super::util::role_level(role) < super::util::role_level(min_role) { + return Err(AppError::Unauthorized); + } + Ok(role) + } + + pub(crate) async fn ensure_workspace_readable( + &self, + user_uid: Uuid, + ws: &crate::models::workspaces::Workspace, + ) -> Result<(), AppError> { + let readable = + crate::models::workspaces::Workspace::is_readable(self.ctx.db.reader(), ws, user_uid) + .await + .map_err(AppError::Database)?; + if readable { + Ok(()) + } else { + Err(AppError::Unauthorized) + } + } + + pub(crate) async fn ensure_workspace_role_at_least( + &self, + user_uid: Uuid, + ws: &crate::models::workspaces::Workspace, + min_role: Role, + ) -> Result { + let role = crate::models::workspaces::Workspace::user_role( + self.ctx.db.reader(), + ws.id, + user_uid, + ws.owner_id, + ) + .await + .map_err(AppError::Database)? + .unwrap_or(Role::Unknown); + if crate::service::util::role_level(role) < crate::service::util::role_level(min_role) { + return Err(AppError::Unauthorized); + } + Ok(role) + } +} diff --git a/service/repo/deploy_keys.rs b/service/repo/deploy_keys.rs new file mode 100644 index 0000000..0be59b6 --- /dev/null +++ b/service/repo/deploy_keys.rs @@ -0,0 +1,174 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::{KeyType, Role}; +use crate::models::repos::RepoDeployKey; +use crate::service::RepoService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, required_text}; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct AddDeployKeyParams { + pub title: String, + pub public_key: String, + pub key_type: String, + pub read_only: Option, +} + +impl RepoService { + pub async fn repo_deploy_keys( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, RepoDeployKey>( + "SELECT id, repo_id, title, public_key, fingerprint_sha256, key_type, read_only, last_used_at, expires_at, revoked_at, created_by, created_at, updated_at FROM repo_deploy_key WHERE repo_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(repo_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn repo_add_deploy_key( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: AddDeployKeyParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let title = required_text(params.title, "title")?; + let public_key = required_text(params.public_key, "public_key")?; + let key_type = params + .key_type + .trim() + .parse::() + .map_err(|_| AppError::BadRequest("invalid key_type".into()))?; + if key_type == KeyType::Unknown { + return Err(AppError::BadRequest("invalid key_type".into())); + } + + use base64::Engine; + let decoded = base64::engine::general_purpose::STANDARD + .decode(public_key.trim()) + .unwrap_or_else(|_| public_key.as_bytes().to_vec()); + let fingerprint = super::deploy_keys::sha256_hex(&decoded); + + let existing = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM repo_deploy_key WHERE fingerprint_sha256 = $1 AND repo_id = $2 AND revoked_at IS NULL LIMIT 1)", + ) + .bind(&fingerprint) + .bind(repo_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if existing { + return Err(AppError::Conflict("deploy key already exists".into())); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let key = sqlx::query_as::<_, RepoDeployKey>( + "INSERT INTO repo_deploy_key (id, repo_id, title, public_key, fingerprint_sha256, key_type, \ + read_only, created_by, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) RETURNING id, repo_id, title, public_key, fingerprint_sha256, key_type, read_only, last_used_at, expires_at, revoked_at, created_by, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(repo_id) + .bind(title) + .bind(&public_key) + .bind(&fingerprint) + .bind(key_type) + .bind(params.read_only.unwrap_or(true)) + .bind(user_uid) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(key) + } + + pub async fn repo_delete_deploy_key( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + key_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE repo_deploy_key SET revoked_at = $1, updated_at = $1 WHERE id = $2 AND repo_id = $3 AND revoked_at IS NULL", + ) + .bind(now) + .bind(key_id) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "key not found")?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } +} + +pub fn sha256_hex(data: &[u8]) -> String { + use sha2::Digest; + sha2::Sha256::digest(data) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} diff --git a/service/repo/fork.rs b/service/repo/fork.rs new file mode 100644 index 0000000..898bc03 --- /dev/null +++ b/service/repo/fork.rs @@ -0,0 +1,258 @@ +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::repos::{Repo, RepoFork}; +use crate::service::RepoService; +use crate::session::Session; + +use super::util::clamp_limit_offset; + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct ForkRepoParams { + pub target_workspace_name: Option, + pub name: Option, +} + +impl RepoService { + pub async fn repo_forks( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, RepoFork>( + "SELECT id, parent_repo_id, fork_repo_id, forked_by, created_at \ + FROM repo_fork WHERE parent_repo_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(repo.id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + #[tracing::instrument(skip(self, ctx, params))] + pub async fn repo_fork( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: ForkRepoParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let parent = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &parent).await?; + + let ws_name = params.target_workspace_name.as_deref().unwrap_or(wk_name); + let ws = self.resolve_workspace(ws_name).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Member) + .await?; + + let fork_name = params.name.as_deref().unwrap_or(repo_name); + + let existing = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM repo WHERE workspace_id = $1 AND name = $2 AND deleted_at IS NULL)", + ) + .bind(ws.id).bind(fork_name) + .fetch_one(self.ctx.db.reader()).await.map_err(AppError::Database)?; + if existing { + return Err(AppError::Conflict( + "repo name already taken in target workspace".into(), + )); + } + + let now = Utc::now(); + let fork_id = Uuid::now_v7(); + let storage_path = format!("repos/{}/{}", ws.id, fork_id); + let storage_node_ids = parent.storage_node_ids.clone(); + let primary_node_id = parent.primary_storage_node_id; + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let fork = sqlx::query_as::<_, Repo>( + "INSERT INTO repo (id, workspace_id, owner_id, name, description, default_branch, \ + visibility, status, is_fork, forked_from_repo_id, storage_node_ids, \ + primary_storage_node_id, storage_path, git_service, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, 'private', 'active', true, $7, $8, $9, $10, $11, $12, $12) \ + RETURNING id, workspace_id, owner_id, name, description, default_branch, visibility, status, \ + is_fork, forked_from_repo_id, storage_node_ids, primary_storage_node_id, storage_path, \ + git_service, archived_at, created_at, updated_at, deleted_at", + ) + .bind(fork_id).bind(ws.id).bind(user_uid) + .bind(fork_name).bind(parent.description.as_deref()) + .bind(&parent.default_branch).bind(parent.id) + .bind(&storage_node_ids).bind(primary_node_id) + .bind(&storage_path).bind(parent.git_service) + .bind(now) + .fetch_one(&mut *txn).await.map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO repo_member (id, repo_id, user_id, role, status, joined_at, created_at, updated_at) \ + VALUES ($1, $2, $3, 'owner', 'active', $4, $4, $4)", + ) + .bind(Uuid::now_v7()).bind(fork_id).bind(user_uid).bind(now) + .execute(&mut *txn).await.map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO repo_stats (repo_id, stars_count, watchers_count, forks_count, branches_count, \ + tags_count, commits_count, releases_count, open_issues_count, open_pull_requests_count, \ + size_bytes, updated_at) \ + VALUES ($1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, $2)", + ) + .bind(fork_id).bind(now) + .execute(&mut *txn).await.map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO repo_branch (id, repo_id, name, commit_sha, protected, default_branch, created_by, created_at, updated_at) \ + VALUES ($1, $2, $3, '', false, true, $4, $5, $5)", + ) + .bind(Uuid::now_v7()).bind(fork_id).bind(&parent.default_branch).bind(user_uid).bind(now) + .execute(&mut *txn).await.map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO repo_fork (id, parent_repo_id, fork_repo_id, forked_by, created_at) \ + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(Uuid::now_v7()) + .bind(parent.id) + .bind(fork_id) + .bind(user_uid) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE repo_stats SET forks_count = forks_count + 1, updated_at = $1 WHERE repo_id = $2", + ).bind(now).bind(parent.id).execute(&mut *txn).await.map_err(AppError::Database)?; + + sqlx::query( + "UPDATE workspace_stats SET repos_count = repos_count + 1, updated_at = $1 WHERE workspace_id = $2", + ).bind(now).bind(ws.id).execute(&mut *txn).await.map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + + if let Some(mut client) = self.ctx.registry.get_git_client(&primary_node_id) { + let parent_ws = self.resolve_workspace(wk_name).await?; + let _header = crate::pb::repo::RepositoryHeader { + storage_name: parent_ws.name.clone(), + relative_path: format!("{}.git", parent.name), + storage_path: parent.storage_path.clone(), + }; + let fork_header = crate::pb::repo::RepositoryHeader { + storage_name: ws.name.clone(), + relative_path: format!("{}.git", fork_name), + storage_path: storage_path.clone(), + }; + let _ = client + .repository + .init_repository(tonic::Request::new( + crate::pb::repo::InitRepositoryRequest { + repository: Some(fork_header), + bare: true, + object_format: crate::pb::repo::ObjectFormat::Sha1 as i32, + initial_branch: parent.default_branch.clone(), + }, + )) + .await; + } + + tracing::info!(fork_id = %fork_id, parent_id = %parent.id, "Repo forked"); + Ok(fork) + } + + pub async fn repo_sync_fork( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let fork = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &fork, Role::Member) + .await?; + + if !fork.is_fork { + return Err(AppError::BadRequest("repo is not a fork".into())); + } + let parent_id = fork + .forked_from_repo_id + .ok_or(AppError::BadRequest("parent repo not found".into()))?; + let parent = Repo::find_by_id(self.ctx.db.reader(), parent_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("parent repo not found".into()))?; + + let header = self.repo_header(&fork, &self.resolve_workspace(wk_name).await?); + let parent_ws = self.find_ws_for_repo(&parent).await?; + let _parent_header = self.repo_header(&parent, &parent_ws); + + let mut client = self.git_client(&fork)?; + let result = client + .merge + .merge(tonic::Request::new(crate::pb::repo::MergeRequest { + repository: Some(header), + target_branch: fork.default_branch.clone(), + source: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: parent.default_branch.clone(), + }, + )), + }), + committer: None, + message: format!("Sync from upstream {}/{}", parent_ws.name, parent.name), + options: None, + })) + .await + .map_err(|e| AppError::InternalServerError(format!("sync failed: {e}")))?; + + let merge_result = result.into_inner(); + if merge_result.status + == crate::pb::repo::merge_result::Status::MergeResultStatusConflicts as i32 + { + return Err(AppError::Conflict( + "sync failed: merge conflicts with upstream".into(), + )); + } + + Ok(fork) + } + + pub(crate) async fn find_ws_for_repo( + &self, + repo: &Repo, + ) -> Result { + sqlx::query_as::<_, crate::models::workspaces::Workspace>( + "SELECT id, owner_id, name, description, avatar_url, visibility, plan, status, \ + default_role, is_personal, archived_at, created_at, updated_at, deleted_at \ + FROM workspace WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(repo.workspace_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("workspace not found".into())) + } +} diff --git a/service/repo/git/blame.rs b/service/repo/git/blame.rs new file mode 100644 index 0000000..a1b99ea --- /dev/null +++ b/service/repo/git/blame.rs @@ -0,0 +1,43 @@ +use crate::error::AppError; +use crate::service::RepoService; +use crate::session::Session; + +impl RepoService { + pub async fn git_blame( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + revision: &str, + path: &str, + page_size: u32, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.blame; + let resp = svc + .blame(tonic::Request::new(crate::pb::repo::BlameRequest { + repository: Some(header), + revision: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: revision.to_string(), + }, + )), + }), + path: path.to_string(), + range: None, + options: None, + pagination: Some(crate::pb::repo::Pagination { + page_size, + page_token: String::new(), + }), + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } +} diff --git a/service/repo/git/branch.rs b/service/repo/git/branch.rs new file mode 100644 index 0000000..d2dd149 --- /dev/null +++ b/service/repo/git/branch.rs @@ -0,0 +1,142 @@ +use crate::error::AppError; +use crate::service::RepoService; +use crate::session::Session; + +impl RepoService { + pub async fn git_list_branches( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + pattern: Option, + page_size: u32, + page_token: Option, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.branch; + let resp = svc + .list_branches(tonic::Request::new(crate::pb::repo::ListBranchesRequest { + repository: Some(header), + pattern: pattern.unwrap_or_default(), + merged_into_head: false, + not_merged_into_head: false, + pagination: Some(crate::pb::repo::Pagination { + page_size, + page_token: page_token.unwrap_or_default(), + }), + sort_direction: crate::pb::repo::SortDirection::Desc as i32, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_get_branch( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + branch_name: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.branch; + let resp = svc + .get_branch(tonic::Request::new(crate::pb::repo::GetBranchRequest { + repository: Some(header), + name: branch_name.to_string(), + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_create_branch( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + branch_name: &str, + start_point: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Member) + .await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.branch; + let resp = svc + .create_branch(tonic::Request::new(crate::pb::repo::CreateBranchRequest { + repository: Some(header), + name: branch_name.to_string(), + start_point: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: start_point.to_string(), + }, + )), + }), + force: false, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_delete_branch( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + branch_name: &str, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Admin) + .await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.branch; + svc.delete_branch(tonic::Request::new(crate::pb::repo::DeleteBranchRequest { + repository: Some(header), + name: branch_name.to_string(), + force: false, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } + + pub async fn git_compare_branches( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + source_branch: &str, + target_branch: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.branch; + let resp = svc + .compare_branch(tonic::Request::new(crate::pb::repo::CompareBranchRequest { + repository: Some(header), + source_branch: source_branch.to_string(), + target_branch: target_branch.to_string(), + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } +} diff --git a/service/repo/git/commit.rs b/service/repo/git/commit.rs new file mode 100644 index 0000000..ec0b6c8 --- /dev/null +++ b/service/repo/git/commit.rs @@ -0,0 +1,79 @@ +use crate::error::AppError; +use crate::service::RepoService; +use crate::session::Session; + +impl RepoService { + pub async fn git_list_commits( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + revision: &str, + path: Option, + page_size: u32, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.commit; + let resp = svc + .list_commits(tonic::Request::new(crate::pb::repo::ListCommitsRequest { + repository: Some(header), + revision: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: revision.to_string(), + }, + )), + }), + path: path.unwrap_or_default(), + since: None, + until: None, + first_parent: false, + all: false, + reverse: false, + max_parents: 0, + min_parents: 0, + pagination: Some(crate::pb::repo::Pagination { + page_size, + page_token: String::new(), + }), + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_get_commit( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + revision: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.commit; + let resp = svc + .get_commit(tonic::Request::new(crate::pb::repo::GetCommitRequest { + repository: Some(header), + revision: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: revision.to_string(), + }, + )), + }), + include_stats: true, + include_raw: false, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } +} diff --git a/service/repo/git/diff.rs b/service/repo/git/diff.rs new file mode 100644 index 0000000..e88795e --- /dev/null +++ b/service/repo/git/diff.rs @@ -0,0 +1,72 @@ +use crate::error::AppError; +use crate::service::RepoService; +use crate::session::Session; + +fn rev(revision: &str) -> crate::pb::repo::ObjectSelector { + crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: revision.to_string(), + }, + )), + } +} + +impl RepoService { + pub async fn git_diff( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + base: &str, + head: &str, + page_size: u32, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.diff; + let resp = svc + .get_diff(tonic::Request::new(crate::pb::repo::GetDiffRequest { + repository: Some(header), + base: Some(rev(base)), + head: Some(rev(head)), + options: None, + pagination: Some(crate::pb::repo::Pagination { + page_size, + page_token: String::new(), + }), + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_diff_stats( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + base: &str, + head: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.diff; + let resp = svc + .get_diff_stats(tonic::Request::new(crate::pb::repo::GetDiffStatsRequest { + repository: Some(header), + base: Some(rev(base)), + head: Some(rev(head)), + options: None, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } +} diff --git a/service/repo/git/merge.rs b/service/repo/git/merge.rs new file mode 100644 index 0000000..80b1c8a --- /dev/null +++ b/service/repo/git/merge.rs @@ -0,0 +1,372 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::AppError; +use crate::service::RepoService; +use crate::session::Session; + +fn rev(r: &str) -> crate::pb::repo::ObjectSelector { + crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: r.to_string(), + }, + )), + } +} + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct MergeParams { + pub target_branch: String, + pub source: String, + pub message: Option, + pub squash: Option, + pub no_commit: Option, +} + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct RebaseParams { + pub branch: String, + pub upstream: String, +} + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct CherryPickParams { + pub commit: String, + pub branch: String, + pub message: Option, + pub mainline: Option, +} + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct RevertParams { + pub commit: String, + pub branch: String, + pub message: Option, +} + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct CreateCommitParams { + pub branch: String, + pub message: String, + pub start_revision: Option, + pub actions: Vec, + pub force: Option, +} + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct CommitAction { + pub action: String, + pub file_path: String, + pub previous_path: Option, + pub content: Option, + pub executable: Option, +} + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct CompareParams { + pub base: String, + pub head: String, + pub page_size: Option, +} + +impl RepoService { + pub async fn git_check_merge( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + target: &str, + source: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.merge; + let resp = svc + .check_merge(tonic::Request::new(crate::pb::repo::CheckMergeRequest { + repository: Some(header), + target: Some(rev(target)), + source: Some(rev(source)), + options: None, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_merge( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: MergeParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Member) + .await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let message = params + .message + .unwrap_or_else(|| format!("Merge {} into {}", params.source, params.target_branch)); + + let options = crate::pb::repo::MergeOptions { + strategy: crate::pb::repo::merge_options::Strategy::MergeStrategyOrt as i32, + fast_forward: + crate::pb::repo::merge_options::FastForwardMode::MergeFastForwardModeAllowed as i32, + squash: params.squash.unwrap_or(false), + no_commit: params.no_commit.unwrap_or(false), + allow_unrelated_histories: false, + strategy_options: vec![], + }; + + let mut svc = self.git_client(&repo)?.merge; + let resp = svc + .merge(tonic::Request::new(crate::pb::repo::MergeRequest { + repository: Some(header), + target_branch: params.target_branch, + source: Some(rev(¶ms.source)), + committer: None, + message, + options: Some(options), + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_rebase( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: RebaseParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Member) + .await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.merge; + let resp = svc + .rebase(tonic::Request::new(crate::pb::repo::RebaseRequest { + repository: Some(header), + branch: params.branch, + upstream: Some(rev(¶ms.upstream)), + committer: None, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_cherry_pick( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: CherryPickParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Member) + .await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let message = params + .message + .unwrap_or_else(|| format!("Cherry-pick {}", params.commit)); + let mut svc = self.git_client(&repo)?.commit; + let resp = svc + .cherry_pick_commit(tonic::Request::new( + crate::pb::repo::CherryPickCommitRequest { + repository: Some(header), + commit: Some(rev(¶ms.commit)), + branch: params.branch, + committer: None, + message, + mainline: params.mainline.unwrap_or(1), + }, + )) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_revert( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: RevertParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Member) + .await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let message = params + .message + .unwrap_or_else(|| format!("Revert {}", params.commit)); + let mut svc = self.git_client(&repo)?.commit; + let resp = svc + .revert_commit(tonic::Request::new(crate::pb::repo::RevertCommitRequest { + repository: Some(header), + commit: Some(rev(¶ms.commit)), + branch: params.branch, + committer: None, + message, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_create_commit( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: CreateCommitParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Member) + .await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + + let actions: Vec = params + .actions + .iter() + .map(|a| { + let action = match a.action.as_str() { + "create" => { + crate::pb::repo::create_commit_action::Action::CreateCommitActionCreate + as i32 + } + "update" => { + crate::pb::repo::create_commit_action::Action::CreateCommitActionUpdate + as i32 + } + "delete" => { + crate::pb::repo::create_commit_action::Action::CreateCommitActionDelete + as i32 + } + "move" => { + crate::pb::repo::create_commit_action::Action::CreateCommitActionMove as i32 + } + "chmod" => { + crate::pb::repo::create_commit_action::Action::CreateCommitActionChmod + as i32 + } + _ => { + crate::pb::repo::create_commit_action::Action::CreateCommitActionUnspecified + as i32 + } + }; + crate::pb::repo::CreateCommitAction { + action, + file_path: a.file_path.clone(), + previous_path: a.previous_path.clone().unwrap_or_default(), + content: a + .content + .as_ref() + .map(|c| c.as_bytes().to_vec()) + .unwrap_or_default(), + encoding: String::new(), + executable: a.executable.unwrap_or(false), + last_commit_oid: None, + } + }) + .collect(); + + let start_revision = params.start_revision.as_ref().map(|r| rev(r)); + let mut svc = self.git_client(&repo)?.commit; + let resp = svc + .create_commit(tonic::Request::new(crate::pb::repo::CreateCommitRequest { + repository: Some(header), + branch: params.branch, + message: params.message, + author: None, + committer: None, + actions, + start_revision, + force: params.force.unwrap_or(false), + trailers: vec![], + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_compare_commits( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: CompareParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let page_size = params.page_size.unwrap_or(30); + let mut svc = self.git_client(&repo)?.commit; + let resp = svc + .compare_commits(tonic::Request::new( + crate::pb::repo::CompareCommitsRequest { + repository: Some(header), + base: Some(rev(¶ms.base)), + head: Some(rev(¶ms.head)), + straight: false, + first_parent: false, + pagination: Some(crate::pb::repo::Pagination { + page_size, + page_token: String::new(), + }), + }, + )) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_list_conflicts( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + target: &str, + source: &str, + page_size: u32, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.merge; + let resp = svc + .list_merge_conflicts(tonic::Request::new( + crate::pb::repo::ListMergeConflictsRequest { + repository: Some(header), + target: Some(rev(target)), + source: Some(rev(source)), + pagination: Some(crate::pb::repo::Pagination { + page_size, + page_token: String::new(), + }), + }, + )) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } +} diff --git a/service/repo/git/mod.rs b/service/repo/git/mod.rs new file mode 100644 index 0000000..012efaf --- /dev/null +++ b/service/repo/git/mod.rs @@ -0,0 +1,32 @@ +pub mod blame; +pub mod branch; +pub mod commit; +pub mod diff; +pub mod merge; +pub mod repository; +pub mod tag; +pub mod tree; + +use crate::error::AppError; +use crate::models::repos::Repo; +use crate::models::workspaces::Workspace; +use crate::pb::RepoClient; +use crate::pb::repo::RepositoryHeader; +use crate::service::RepoService; + +impl RepoService { + pub(crate) fn repo_header(&self, repo: &Repo, ws: &Workspace) -> RepositoryHeader { + RepositoryHeader { + storage_name: ws.name.clone(), + relative_path: format!("{}.git", repo.name), + storage_path: repo.storage_path.clone(), + } + } + + pub(crate) fn git_client(&self, repo: &Repo) -> Result { + self.ctx + .registry + .get_git_client(&repo.primary_storage_node_id) + .ok_or_else(|| AppError::Config("primary git node not available".into())) + } +} diff --git a/service/repo/git/repository.rs b/service/repo/git/repository.rs new file mode 100644 index 0000000..8960ba6 --- /dev/null +++ b/service/repo/git/repository.rs @@ -0,0 +1,123 @@ +use crate::error::AppError; +use crate::service::RepoService; +use crate::session::Session; + +impl RepoService { + pub async fn git_repo_info( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.repository; + let resp = svc + .get_repository(tonic::Request::new(crate::pb::repo::GetRepositoryRequest { + repository: Some(header), + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_repo_exists( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.repository; + let resp = svc + .repository_exists(tonic::Request::new( + crate::pb::repo::RepositoryExistsRequest { + repository: Some(header), + }, + )) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner().exists) + } + + pub async fn git_repo_stats( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.repository; + let resp = svc + .get_repository_statistics(tonic::Request::new( + crate::pb::repo::RepositoryStatisticsRequest { + repository: Some(header), + }, + )) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_repo_health( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Admin) + .await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.repository; + let resp = svc + .check_repository_health(tonic::Request::new( + crate::pb::repo::RepositoryHealthRequest { + repository: Some(header), + connectivity_only: false, + }, + )) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_garbage_collect( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Admin) + .await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.repository; + let resp = svc + .garbage_collect(tonic::Request::new( + crate::pb::repo::GarbageCollectRequest { + repository: Some(header), + prune: true, + aggressive: false, + }, + )) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } +} diff --git a/service/repo/git/tag.rs b/service/repo/git/tag.rs new file mode 100644 index 0000000..43fef6f --- /dev/null +++ b/service/repo/git/tag.rs @@ -0,0 +1,96 @@ +use crate::error::AppError; +use crate::service::RepoService; +use crate::session::Session; + +impl RepoService { + pub async fn git_list_tags( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + pattern: Option, + page_size: u32, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.tag; + let resp = svc + .list_tags(tonic::Request::new(crate::pb::repo::ListTagsRequest { + repository: Some(header), + pattern: pattern.unwrap_or_default(), + pagination: Some(crate::pb::repo::Pagination { + page_size, + page_token: String::new(), + }), + sort_direction: crate::pb::repo::SortDirection::Desc as i32, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + #[allow(clippy::too_many_arguments)] + pub async fn git_create_tag( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + tag_name: &str, + target: &str, + message: Option, + annotated: bool, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Member) + .await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.tag; + let resp = svc + .create_tag(tonic::Request::new(crate::pb::repo::CreateTagRequest { + repository: Some(header), + name: tag_name.to_string(), + target: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: target.to_string(), + }, + )), + }), + message: message.unwrap_or_default(), + tagger: None, + force: false, + annotated, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_delete_tag( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + tag_name: &str, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Admin) + .await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.tag; + svc.delete_tag(tonic::Request::new(crate::pb::repo::DeleteTagRequest { + repository: Some(header), + name: tag_name.to_string(), + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } +} diff --git a/service/repo/git/tree.rs b/service/repo/git/tree.rs new file mode 100644 index 0000000..c25037d --- /dev/null +++ b/service/repo/git/tree.rs @@ -0,0 +1,77 @@ +use crate::error::AppError; +use crate::service::RepoService; +use crate::session::Session; + +impl RepoService { + #[allow(clippy::too_many_arguments)] + pub async fn git_list_tree( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + revision: &str, + path: Option, + recursive: bool, + page_size: u32, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.tree; + let resp = svc + .list_tree(tonic::Request::new(crate::pb::repo::ListTreeRequest { + repository: Some(header), + revision: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: revision.to_string(), + }, + )), + }), + path: path.unwrap_or_default(), + recursive, + pagination: Some(crate::pb::repo::Pagination { + page_size, + page_token: String::new(), + }), + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_get_blob( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + revision: &str, + path: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.tree; + let resp = svc + .get_blob(tonic::Request::new(crate::pb::repo::GetBlobRequest { + repository: Some(header), + revision: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: revision.to_string(), + }, + )), + }), + path: path.to_string(), + oid: None, + max_bytes: 0, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } +} diff --git a/service/repo/invitations.rs b/service/repo/invitations.rs new file mode 100644 index 0000000..a3cde35 --- /dev/null +++ b/service/repo/invitations.rs @@ -0,0 +1,343 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::repos::RepoInvitation; +use crate::pb::email::{EmailAddress, SendEmailRequest}; +use crate::service::RepoService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, role_level}; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateRepoInvitationParams { + pub email: String, + pub role: Option, +} + +#[derive(Serialize, Clone, Debug)] +pub struct CreateRepoInvitationResponse { + pub invitation: RepoInvitation, +} + +impl RepoService { + pub async fn repo_invitations( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, RepoInvitation>( + "SELECT id, repo_id, email, role, token_hash, invited_by, accepted_by, accepted_at, revoked_at, expires_at, created_at FROM repo_invitation \ + WHERE repo_id = $1 AND revoked_at IS NULL AND accepted_at IS NULL \ + AND expires_at > NOW() ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(repo_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn repo_create_invitation( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: CreateRepoInvitationParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + let actor_role = self + .ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let email = params.email.trim().to_lowercase(); + if email.is_empty() { + return Err(AppError::BadRequest("email is required".into())); + } + + let existing = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM repo_invitation \ + WHERE repo_id = $1 AND lower(email) = lower($2) \ + AND revoked_at IS NULL AND accepted_at IS NULL AND expires_at > NOW())", + ) + .bind(repo_id) + .bind(&email) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if existing { + return Err(AppError::BadRequest( + "invitation already exists for this email".into(), + )); + } + + let role = params + .role + .as_deref() + .and_then(|r| r.parse::().ok()) + .unwrap_or(Role::Member); + + if role == Role::Owner || role == Role::Unknown { + return Err(AppError::BadRequest("invalid role for invitation".into())); + } + + // Non-owner admins cannot invite with roles equal to or higher than their own + if actor_role != Role::Owner && role_level(role) >= role_level(actor_role) { + return Err(AppError::BadRequest( + "cannot invite with role equal to or higher than your own".into(), + )); + } + + let token = generate_repo_invitation_token(); + let token_hash = sha256_hex(token.as_bytes()); + let now = chrono::Utc::now(); + let expires_at = now + chrono::Duration::days(7); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let invitation = sqlx::query_as::<_, RepoInvitation>( + "INSERT INTO repo_invitation (id, repo_id, email, role, token_hash, invited_by, expires_at, created_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \ + RETURNING id, repo_id, email, role, token_hash, invited_by, accepted_by, accepted_at, revoked_at, expires_at, created_at", + ) + .bind(Uuid::now_v7()) + .bind(repo_id) + .bind(&email) + .bind(role.to_string()) + .bind(&token_hash) + .bind(user_uid) + .bind(expires_at) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + + let domain = self.ctx.config.main_domain()?; + let invite_link = format!("{}/repo/invitations/accept?token={}", domain, token); + let mut mail = self + .ctx + .registry + .get_email_client() + .ok_or(AppError::Config("mail service not available".into()))?; + mail.send_email(tonic::Request::new(SendEmailRequest { + to: vec![EmailAddress { + email: email.clone(), + name: String::new(), + }], + subject: format!("You're invited to join repo {}", repo.name), + text_body: format!( + "You've been invited to join repository '{}'.\n\nAccept the invitation here:\n\n{}\n\nThis invitation expires in 7 days.", + repo.name, invite_link + ), + ..Default::default() + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + tracing::info!(email = %email, invitation_id = %invitation.id, repo_id = %repo_id, "Repo invitation created"); + Ok(CreateRepoInvitationResponse { invitation }) + } + + pub async fn repo_revoke_invitation( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + invitation_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE repo_invitation SET revoked_at = $1 WHERE id = $2 AND repo_id = $3 \ + AND revoked_at IS NULL AND accepted_at IS NULL", + ) + .bind(now) + .bind(invitation_id) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected( + result.rows_affected(), + "invitation not found or already used", + )?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn repo_accept_invitation( + &self, + ctx: &Session, + token: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let token_hash = sha256_hex(token.as_bytes()); + let now = chrono::Utc::now(); + + let invitation = sqlx::query_as::<_, RepoInvitation>( + "SELECT id, repo_id, email, role, token_hash, invited_by, accepted_by, accepted_at, revoked_at, expires_at, created_at FROM repo_invitation \ + WHERE token_hash = $1 AND revoked_at IS NULL AND accepted_at IS NULL AND expires_at > NOW()", + ) + .bind(&token_hash) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::BadRequest("invalid or expired invitation".into()))?; + + let already_member = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM repo_member WHERE repo_id = $1 AND user_id = $2)", + ) + .bind(invitation.repo_id) + .bind(user_uid) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if already_member { + return Err(AppError::BadRequest("already a member of this repo".into())); + } + + let user_email: Option = sqlx::query_scalar( + "SELECT email FROM user_mail WHERE user_id = $1 AND is_verified = true LIMIT 1", + ) + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + if user_email + .as_deref() + .map(|e| e.trim().eq_ignore_ascii_case(&invitation.email)) + != Some(true) + { + return Err(AppError::Unauthorized); + } + + let repo = self.find_repo_by_id(invitation.repo_id).await?; + let role_str = invitation.role.to_string(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let workspace_member_result = sqlx::query( + "INSERT INTO workspace_member (id, workspace_id, user_id, role, status, joined_at, created_at, updated_at) \ + VALUES ($1, $2, $3, 'member', 'active', $4, $4, $4) ON CONFLICT (workspace_id, user_id) DO NOTHING", + ) + .bind(Uuid::now_v7()) + .bind(repo.workspace_id) + .bind(user_uid) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + if workspace_member_result.rows_affected() > 0 { + sqlx::query( + "UPDATE workspace_stats SET members_count = members_count + 1, updated_at = $1 WHERE workspace_id = $2", + ) + .bind(now) + .bind(repo.workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } + + let result = sqlx::query_as::<_, RepoInvitation>( + "UPDATE repo_invitation SET accepted_by = $1, accepted_at = $2 \ + WHERE id = $3 AND revoked_at IS NULL AND accepted_at IS NULL \ + RETURNING id, repo_id, email, role, token_hash, invited_by, accepted_by, accepted_at, revoked_at, expires_at, created_at", + ) + .bind(user_uid) + .bind(now) + .bind(invitation.id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO repo_member (id, repo_id, user_id, role, status, invited_by, joined_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, 'active', $5, $6, $6, $6) \ + ON CONFLICT (repo_id, user_id) DO NOTHING", + ) + .bind(Uuid::now_v7()) + .bind(invitation.repo_id) + .bind(user_uid) + .bind(&role_str) + .bind(invitation.invited_by) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } +} + +fn sha256_hex(data: &[u8]) -> String { + use sha2::Digest; + sha2::Sha256::digest(data) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +fn generate_repo_invitation_token() -> String { + (0..64) + .map(|_| format!("{:02x}", rand::random::())) + .collect() +} diff --git a/service/repo/members.rs b/service/repo/members.rs new file mode 100644 index 0000000..f670a34 --- /dev/null +++ b/service/repo/members.rs @@ -0,0 +1,317 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::repos::RepoMember; +use crate::service::RepoService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, role_level}; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct AddRepoMemberParams { + pub user_id: Uuid, + pub role: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateRepoMemberRoleParams { + pub role: String, +} + +impl RepoService { + pub async fn repo_members( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_readable(user_uid, &repo).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, RepoMember>( + "SELECT id, repo_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at FROM repo_member WHERE repo_id = $1 AND status = 'active' ORDER BY created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(repo_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn repo_add_member( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: AddRepoMemberParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + let actor_role = self + .ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let target_workspace_member = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')", + ) + .bind(repo.workspace_id) + .bind(params.user_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + if !target_workspace_member { + return Err(AppError::BadRequest( + "user must be a workspace member".into(), + )); + } + + let existing = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM repo_member WHERE repo_id = $1 AND user_id = $2)", + ) + .bind(repo_id) + .bind(params.user_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if existing { + return Err(AppError::Conflict("user is already a member".into())); + } + + let role = params + .role + .as_deref() + .and_then(|r| r.parse::().ok()) + .unwrap_or_else(|| "member".to_string().parse().unwrap_or(Role::Member)); + + if role == Role::Owner { + return Err(AppError::BadRequest("cannot add member as owner".into())); + } + if role == Role::Unknown { + return Err(AppError::BadRequest("invalid role".into())); + } + if role_level(actor_role) < role_level(Role::Owner) + && role_level(role) >= role_level(actor_role) + { + return Err(AppError::BadRequest( + "cannot assign role equal or higher than your own".into(), + )); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let member = sqlx::query_as::<_, RepoMember>( + "INSERT INTO repo_member (id, repo_id, user_id, role, status, invited_by, joined_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, 'active', $5, $6, $6, $6) RETURNING id, repo_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(repo_id) + .bind(params.user_id) + .bind(role.to_string()) + .bind(user_uid) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(member) + } + + pub async fn repo_update_member_role( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + member_id: Uuid, + params: UpdateRepoMemberRoleParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + let actor_role = self + .ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let new_role = params + .role + .parse::() + .map_err(|_| AppError::BadRequest("invalid role".into()))?; + if new_role == Role::Owner { + return Err(AppError::BadRequest( + "use repo_transfer_owner to change owner".into(), + )); + } + if new_role == Role::Unknown { + return Err(AppError::BadRequest("invalid role".into())); + } + + let target = sqlx::query_as::<_, RepoMember>( + "SELECT id, repo_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at FROM repo_member WHERE id = $1 AND repo_id = $2", + ) + .bind(member_id) + .bind(repo_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("member not found".into()))?; + + if target.role == Role::Owner { + return Err(AppError::BadRequest("cannot change owner role".into())); + } + if role_level(actor_role) <= role_level(target.role) && actor_role != Role::Owner { + return Err(AppError::BadRequest( + "cannot change role of member with equal or higher role".into(), + )); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, RepoMember>( + "UPDATE repo_member SET role = $1, updated_at = $2 WHERE id = $3 AND repo_id = $4 RETURNING id, repo_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at", + ) + .bind(new_role.to_string()) + .bind(now) + .bind(member_id) + .bind(repo_id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn repo_remove_member( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + member_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + let actor_role = self + .ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let target = sqlx::query_as::<_, RepoMember>( + "SELECT id, repo_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at FROM repo_member WHERE id = $1 AND repo_id = $2", + ) + .bind(member_id) + .bind(repo_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("member not found".into()))?; + + if target.role == Role::Owner { + return Err(AppError::BadRequest( + "cannot remove owner; transfer ownership first".into(), + )); + } + if role_level(actor_role) <= role_level(target.role) && actor_role != Role::Owner { + return Err(AppError::BadRequest( + "cannot remove a member with equal or higher role".into(), + )); + } + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query("DELETE FROM repo_member WHERE id = $1 AND repo_id = $2") + .bind(member_id) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "member not found")?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn repo_leave( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + + if repo.owner_id == user_uid { + return Err(AppError::BadRequest( + "owner cannot leave; transfer ownership first".into(), + )); + } + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query("DELETE FROM repo_member WHERE repo_id = $1 AND user_id = $2") + .bind(repo_id) + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "not a member")?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } +} diff --git a/service/repo/mod.rs b/service/repo/mod.rs new file mode 100644 index 0000000..2141ba3 --- /dev/null +++ b/service/repo/mod.rs @@ -0,0 +1,16 @@ +pub mod branches; +pub mod commit_status; +pub mod core; +pub mod deploy_keys; +pub mod fork; +pub mod git; +pub mod invitations; +pub mod members; +pub mod protection; +pub mod releases; +pub mod stars; +pub mod stats; +pub mod tags; +pub mod util; +pub mod watches; +pub mod webhooks; diff --git a/service/repo/protection.rs b/service/repo/protection.rs new file mode 100644 index 0000000..d0a6bf5 --- /dev/null +++ b/service/repo/protection.rs @@ -0,0 +1,389 @@ +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::repos::BranchProtectionRule; +use crate::service::RepoService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, required_text}; + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct CreateProtectionRuleParams { + pub pattern: String, + pub require_approvals: Option, + pub require_status_checks: Option, + pub required_status_checks: Option>, + pub require_linear_history: Option, + pub allow_force_pushes: Option, + pub allow_deletions: Option, + pub require_signed_commits: Option, + pub require_code_owner_review: Option, + pub dismiss_stale_reviews: Option, + pub restrict_pushes: Option, + pub push_allowances: Option>, + pub restrict_review_dismissal: Option, + pub dismissal_allowances: Option>, + pub require_conversation_resolution: Option, +} + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct UpdateProtectionRuleParams { + pub require_approvals: Option, + pub require_status_checks: Option, + pub required_status_checks: Option>, + pub require_linear_history: Option, + pub allow_force_pushes: Option, + pub allow_deletions: Option, + pub require_signed_commits: Option, + pub require_code_owner_review: Option, + pub dismiss_stale_reviews: Option, + pub restrict_pushes: Option, + pub push_allowances: Option>, + pub restrict_review_dismissal: Option, + pub dismissal_allowances: Option>, + pub require_conversation_resolution: Option, +} + +impl RepoService { + pub async fn repo_protection_rules( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, BranchProtectionRule>( + "SELECT id, repo_id, pattern, require_approvals, require_status_checks, \ + required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \ + require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \ + restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \ + require_conversation_resolution, created_by, created_at, updated_at \ + FROM branch_protection_rule WHERE repo_id = $1 ORDER BY pattern ASC LIMIT $2 OFFSET $3", + ) + .bind(repo.id).bind(limit).bind(offset) + .fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database) + } + + pub async fn repo_get_protection_rule( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + rule_id: Uuid, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + sqlx::query_as::<_, BranchProtectionRule>( + "SELECT id, repo_id, pattern, require_approvals, require_status_checks, \ + required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \ + require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \ + restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \ + require_conversation_resolution, created_by, created_at, updated_at \ + FROM branch_protection_rule WHERE id = $1 AND repo_id = $2", + ) + .bind(rule_id) + .bind(repo.id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("protection rule not found".into())) + } + + pub async fn repo_match_protection( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + branch_name: &str, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + + let rules = sqlx::query_as::<_, BranchProtectionRule>( + "SELECT id, repo_id, pattern, require_approvals, require_status_checks, \ + required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \ + require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \ + restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \ + require_conversation_resolution, created_by, created_at, updated_at \ + FROM branch_protection_rule WHERE repo_id = $1 ORDER BY pattern ASC", + ) + .bind(repo.id) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + Ok(rules + .into_iter() + .find(|r| glob_match(&r.pattern, branch_name))) + } + + pub async fn repo_create_protection_rule( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: CreateProtectionRuleParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let pattern = required_text(params.pattern, "pattern")?; + let now = Utc::now(); + let rule_id = Uuid::now_v7(); + let required_checks = params.required_status_checks.unwrap_or_default(); + let push_allow = params.push_allowances.unwrap_or_default(); + let dismiss_allow = params.dismissal_allowances.unwrap_or_default(); + + sqlx::query( + "INSERT INTO branch_protection_rule (id, repo_id, pattern, require_approvals, \ + require_status_checks, required_status_checks, require_linear_history, allow_force_pushes, \ + allow_deletions, require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \ + restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \ + require_conversation_resolution, created_by, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $19)", + ) + .bind(rule_id).bind(repo.id).bind(&pattern) + .bind(params.require_approvals.unwrap_or(0)) + .bind(params.require_status_checks.unwrap_or(false)) + .bind(&required_checks) + .bind(params.require_linear_history.unwrap_or(false)) + .bind(params.allow_force_pushes.unwrap_or(false)) + .bind(params.allow_deletions.unwrap_or(false)) + .bind(params.require_signed_commits.unwrap_or(false)) + .bind(params.require_code_owner_review.unwrap_or(false)) + .bind(params.dismiss_stale_reviews.unwrap_or(false)) + .bind(params.restrict_pushes.unwrap_or(false)) + .bind(&push_allow) + .bind(params.restrict_review_dismissal.unwrap_or(false)) + .bind(&dismiss_allow) + .bind(params.require_conversation_resolution.unwrap_or(false)) + .bind(user_uid).bind(now) + .execute(self.ctx.db.writer()).await.map_err(AppError::Database)?; + + sqlx::query_as::<_, BranchProtectionRule>( + "SELECT id, repo_id, pattern, require_approvals, require_status_checks, \ + required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \ + require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \ + restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \ + require_conversation_resolution, created_by, created_at, updated_at \ + FROM branch_protection_rule WHERE id = $1", + ) + .bind(rule_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn repo_update_protection_rule( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + rule_id: Uuid, + params: UpdateProtectionRuleParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let existing = sqlx::query_as::<_, BranchProtectionRule>( + "SELECT id, repo_id, pattern, require_approvals, require_status_checks, \ + required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \ + require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \ + restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \ + require_conversation_resolution, created_by, created_at, updated_at \ + FROM branch_protection_rule WHERE id = $1 AND repo_id = $2", + ) + .bind(rule_id) + .bind(repo.id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("protection rule not found".into()))?; + + let now = Utc::now(); + sqlx::query( + "UPDATE branch_protection_rule SET \ + require_approvals = $1, require_status_checks = $2, required_status_checks = $3, \ + require_linear_history = $4, allow_force_pushes = $5, allow_deletions = $6, \ + require_signed_commits = $7, require_code_owner_review = $8, dismiss_stale_reviews = $9, \ + restrict_pushes = $10, push_allowances = $11, restrict_review_dismissal = $12, \ + dismissal_allowances = $13, require_conversation_resolution = $14, updated_at = $15 \ + WHERE id = $16", + ) + .bind(params.require_approvals.unwrap_or(existing.require_approvals)) + .bind(params.require_status_checks.unwrap_or(existing.require_status_checks)) + .bind(params.required_status_checks.as_ref().unwrap_or(&existing.required_status_checks)) + .bind(params.require_linear_history.unwrap_or(existing.require_linear_history)) + .bind(params.allow_force_pushes.unwrap_or(existing.allow_force_pushes)) + .bind(params.allow_deletions.unwrap_or(existing.allow_deletions)) + .bind(params.require_signed_commits.unwrap_or(existing.require_signed_commits)) + .bind(params.require_code_owner_review.unwrap_or(existing.require_code_owner_review)) + .bind(params.dismiss_stale_reviews.unwrap_or(existing.dismiss_stale_reviews)) + .bind(params.restrict_pushes.unwrap_or(existing.restrict_pushes)) + .bind(params.push_allowances.as_ref().unwrap_or(&existing.push_allowances)) + .bind(params.restrict_review_dismissal.unwrap_or(existing.restrict_review_dismissal)) + .bind(params.dismissal_allowances.as_ref().unwrap_or(&existing.dismissal_allowances)) + .bind(params.require_conversation_resolution.unwrap_or(existing.require_conversation_resolution)) + .bind(now).bind(rule_id) + .execute(self.ctx.db.writer()).await.map_err(AppError::Database)?; + + sqlx::query_as::<_, BranchProtectionRule>( + "SELECT id, repo_id, pattern, require_approvals, require_status_checks, \ + required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \ + require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \ + restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \ + require_conversation_resolution, created_by, created_at, updated_at \ + FROM branch_protection_rule WHERE id = $1", + ) + .bind(rule_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn repo_delete_protection_rule( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + rule_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + let result = + sqlx::query("DELETE FROM branch_protection_rule WHERE id = $1 AND repo_id = $2") + .bind(rule_id) + .bind(repo.id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "protection rule not found") + } + + pub async fn repo_check_branch_merge_allowed( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + target_branch: &str, + pr_number: i64, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + + let rule = self + .repo_match_protection(ctx, wk_name, repo_name, target_branch) + .await?; + let Some(rule) = rule else { + return Ok(BranchMergeCheck { + allowed: true, + reasons: vec![], + }); + }; + + let mut reasons = Vec::new(); + + if rule.require_approvals > 0 { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM pr_review r \ + JOIN pull_request pr ON pr.id = r.pull_request_id \ + WHERE pr.repo_id = $1 AND pr.number = $2 AND pr.deleted_at IS NULL \ + AND r.state = 'approved' AND r.dismissed_at IS NULL AND r.submitted_at IS NOT NULL", + ) + .bind(repo.id).bind(pr_number) + .fetch_one(self.ctx.db.reader()).await.map_err(AppError::Database)?; + if count < rule.require_approvals as i64 { + reasons.push(format!( + "requires {} approvals, has {}", + rule.require_approvals, count + )); + } + } + + if rule.require_status_checks && !rule.required_status_checks.is_empty() { + let passed: Vec = sqlx::query_scalar( + "SELECT DISTINCT cr.context FROM repo_commit_status cr \ + JOIN pull_request pr ON pr.head_commit_sha = cr.latest_commit_sha \ + WHERE pr.repo_id = $1 AND pr.number = $2 AND pr.deleted_at IS NULL \ + AND cr.state = 'success'", + ) + .bind(repo.id) + .bind(pr_number) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + for required in &rule.required_status_checks { + if !passed.contains(required) { + reasons.push(format!("required check '{}' has not passed", required)); + } + } + } + + Ok(BranchMergeCheck { + allowed: reasons.is_empty(), + reasons, + }) + } +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct BranchMergeCheck { + pub allowed: bool, + pub reasons: Vec, +} + +fn glob_match(pattern: &str, text: &str) -> bool { + if pattern == text { + return true; + } + if pattern == "*" { + return true; + } + + let p: Vec = pattern.chars().collect(); + let t: Vec = text.chars().collect(); + let (mut pi, mut ti) = (0usize, 0usize); + let (mut star_pi, mut star_ti) = (None, None); + + loop { + if pi < p.len() && ti < t.len() && (p[pi] == '?' || p[pi] == t[ti]) { + pi += 1; + ti += 1; + continue; + } + if pi < p.len() && p[pi] == '*' { + star_pi = Some(pi); + star_ti = Some(ti); + pi += 1; + continue; + } + if let (Some(sp), Some(st)) = (star_pi, star_ti) + && st < t.len() + { + pi = sp + 1; + let nt = st + 1; + star_ti = Some(nt); + ti = nt; + continue; + } + return pi == p.len() && ti == t.len(); + } +} diff --git a/service/repo/releases.rs b/service/repo/releases.rs new file mode 100644 index 0000000..8d67b75 --- /dev/null +++ b/service/repo/releases.rs @@ -0,0 +1,244 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::repos::RepoRelease; +use crate::service::RepoService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, merge_optional_text, required_text}; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateReleaseParams { + pub tag_name: String, + pub title: String, + pub body: Option, + pub draft: Option, + pub prerelease: Option, + pub tag_id: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateReleaseParams { + pub title: Option, + pub body: Option, + pub draft: Option, + pub prerelease: Option, +} + +impl RepoService { + pub async fn repo_releases( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_readable(user_uid, &repo).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, RepoRelease>( + "SELECT id, repo_id, tag_id, tag_name, title, body, draft, prerelease, author_id, published_at, created_at, updated_at, deleted_at FROM repo_release WHERE repo_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(repo_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn repo_create_release( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: CreateReleaseParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + let tag_name = required_text(params.tag_name, "tag_name")?; + let title = required_text(params.title, "title")?; + let now = chrono::Utc::now(); + let published_at = if params.draft.unwrap_or(false) { + None + } else { + Some(now) + }; + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let release = sqlx::query_as::<_, RepoRelease>( + "INSERT INTO repo_release (id, repo_id, tag_id, tag_name, title, body, draft, prerelease, \ + author_id, published_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) RETURNING id, repo_id, tag_id, tag_name, title, body, draft, prerelease, author_id, published_at, created_at, updated_at, deleted_at", + ) + .bind(Uuid::now_v7()) + .bind(repo_id) + .bind(params.tag_id) + .bind(&tag_name) + .bind(&title) + .bind(¶ms.body) + .bind(params.draft.unwrap_or(false)) + .bind(params.prerelease.unwrap_or(false)) + .bind(user_uid) + .bind(published_at) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE repo_stats SET releases_count = releases_count + 1, updated_at = $1 WHERE repo_id = $2", + ) + .bind(now) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(release) + } + + pub async fn repo_update_release( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + release_id: Uuid, + params: UpdateReleaseParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + let actor_role = self + .ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + + let current = sqlx::query_as::<_, RepoRelease>( + "SELECT id, repo_id, tag_id, tag_name, title, body, draft, prerelease, author_id, published_at, created_at, updated_at, deleted_at FROM repo_release WHERE id = $1 AND repo_id = $2 AND deleted_at IS NULL", + ) + .bind(release_id) + .bind(repo_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("release not found".into()))?; + + if crate::service::repo::util::role_level(actor_role) + < crate::service::repo::util::role_level(Role::Admin) + && current.author_id != user_uid + { + return Err(AppError::Unauthorized); + } + + let title = + merge_optional_text(params.title, Some(current.title.clone())).unwrap_or(current.title); + let body = merge_optional_text(params.body, current.body); + let draft = params.draft.unwrap_or(current.draft); + let prerelease = params.prerelease.unwrap_or(current.prerelease); + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, RepoRelease>( + "UPDATE repo_release SET title = $1, body = $2, draft = $3, prerelease = $4, \ + published_at = CASE WHEN $3 = false AND published_at IS NULL THEN $5 ELSE published_at END, \ + updated_at = $5 WHERE id = $6 AND repo_id = $7 RETURNING id, repo_id, tag_id, tag_name, title, body, draft, prerelease, author_id, published_at, created_at, updated_at, deleted_at", + ) + .bind(&title) + .bind(&body) + .bind(draft) + .bind(prerelease) + .bind(now) + .bind(release_id) + .bind(repo_id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn repo_delete_release( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + release_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE repo_release SET deleted_at = $1, updated_at = $1 WHERE id = $2 AND repo_id = $3 AND deleted_at IS NULL", + ) + .bind(now) + .bind(release_id) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "release not found")?; + + sqlx::query( + "UPDATE repo_stats SET releases_count = GREATEST(releases_count - 1, 0), updated_at = $1 WHERE repo_id = $2", + ) + .bind(now) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } +} diff --git a/service/repo/stars.rs b/service/repo/stars.rs new file mode 100644 index 0000000..a4feec7 --- /dev/null +++ b/service/repo/stars.rs @@ -0,0 +1,142 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::repos::RepoStar; +use crate::service::RepoService; +use crate::session::Session; + +use super::util::clamp_limit_offset; + +impl RepoService { + pub async fn repo_star( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_readable(user_uid, &repo).await?; + + let existing = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM repo_star WHERE repo_id = $1 AND user_id = $2)", + ) + .bind(repo_id) + .bind(user_uid) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if existing { + return Ok(()); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO repo_star (id, repo_id, user_id, created_at) VALUES ($1, $2, $3, $4)", + ) + .bind(Uuid::now_v7()) + .bind(repo_id) + .bind(user_uid) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE repo_stats SET stars_count = stars_count + 1, updated_at = $1 WHERE repo_id = $2", + ) + .bind(now) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn repo_unstar( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query("DELETE FROM repo_star WHERE repo_id = $1 AND user_id = $2") + .bind(repo_id) + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + if result.rows_affected() > 0 { + sqlx::query( + "UPDATE repo_stats SET stars_count = GREATEST(stars_count - 1, 0), updated_at = $1 WHERE repo_id = $2", + ) + .bind(now) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn repo_stargazers( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_readable(user_uid, &repo).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, RepoStar>( + "SELECT id, repo_id, user_id, created_at FROM repo_star WHERE repo_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(repo_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } +} diff --git a/service/repo/stats.rs b/service/repo/stats.rs new file mode 100644 index 0000000..3a3104a --- /dev/null +++ b/service/repo/stats.rs @@ -0,0 +1,152 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::repos::RepoStats; +use crate::service::RepoService; +use crate::session::Session; + +impl RepoService { + pub async fn repo_stats( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_readable(user_uid, &repo).await?; + self.ensure_repo_stats(repo_id).await + } + + pub async fn repo_refresh_stats( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let branches_count = + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM repo_branch WHERE repo_id = $1") + .bind(repo_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let tags_count = + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM repo_tag WHERE repo_id = $1") + .bind(repo_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let releases_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM repo_release WHERE repo_id = $1 AND deleted_at IS NULL", + ) + .bind(repo_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let stars_count = + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM repo_star WHERE repo_id = $1") + .bind(repo_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let watchers_count = + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM repo_watch WHERE repo_id = $1") + .bind(repo_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let forks_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM repo WHERE forked_from_repo_id = $1 AND deleted_at IS NULL", + ) + .bind(repo_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let open_issues_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM issue i \ + JOIN issue_repo_relation irr ON irr.issue_id = i.id \ + WHERE irr.repo_id = $1 AND i.deleted_at IS NULL AND i.state = 'open'", + ) + .bind(repo_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let open_prs_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM pull_request WHERE repo_id = $1 AND deleted_at IS NULL AND state = 'open'", + ) + .bind(repo_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let now = chrono::Utc::now(); + let result = sqlx::query_as::<_, RepoStats>( + "UPDATE repo_stats SET stars_count = $1, watchers_count = $2, forks_count = $3, \ + branches_count = $4, tags_count = $5, releases_count = $6, \ + open_issues_count = $7, open_pull_requests_count = $8, updated_at = $9 \ + WHERE repo_id = $10 RETURNING repo_id, stars_count, watchers_count, forks_count, branches_count, tags_count, commits_count, releases_count, open_issues_count, open_pull_requests_count, size_bytes, last_push_at, updated_at", + ) + .bind(stars_count) + .bind(watchers_count) + .bind(forks_count) + .bind(branches_count) + .bind(tags_count) + .bind(releases_count) + .bind(open_issues_count) + .bind(open_prs_count) + .bind(now) + .bind(repo_id) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + Ok(result) + } + + async fn ensure_repo_stats(&self, repo_id: Uuid) -> Result { + if let Some(stats) = sqlx::query_as::<_, RepoStats>( + "SELECT repo_id, stars_count, watchers_count, forks_count, branches_count, tags_count, commits_count, releases_count, open_issues_count, open_pull_requests_count, size_bytes, last_push_at, updated_at FROM repo_stats WHERE repo_id = $1", + ) + .bind(repo_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + { + return Ok(stats); + } + + sqlx::query( + "INSERT INTO repo_stats (repo_id, stars_count, watchers_count, forks_count, branches_count, \ + tags_count, commits_count, releases_count, open_issues_count, open_pull_requests_count, \ + size_bytes, updated_at) \ + VALUES ($1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, $2) ON CONFLICT (repo_id) DO NOTHING", + ) + .bind(repo_id) + .bind(chrono::Utc::now()) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + sqlx::query_as::<_, RepoStats>( + "SELECT repo_id, stars_count, watchers_count, forks_count, branches_count, tags_count, commits_count, releases_count, open_issues_count, open_pull_requests_count, size_bytes, last_push_at, updated_at FROM repo_stats WHERE repo_id = $1", + ) + .bind(repo_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } +} diff --git a/service/repo/tags.rs b/service/repo/tags.rs new file mode 100644 index 0000000..3ac5fb0 --- /dev/null +++ b/service/repo/tags.rs @@ -0,0 +1,159 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::repos::RepoTag; +use crate::service::RepoService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, required_text}; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateTagParams { + pub name: String, + pub target_commit_sha: String, + pub message: Option, +} + +impl RepoService { + pub async fn repo_tags( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_readable(user_uid, &repo).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, RepoTag>( + "SELECT id, repo_id, name, target_commit_sha, tagger_id, message, signed, created_at FROM repo_tag WHERE repo_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(repo_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn repo_create_tag( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: CreateTagParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + let name = required_text(params.name, "name")?; + let target = required_text(params.target_commit_sha, "target_commit_sha")?; + + let existing = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM repo_tag WHERE repo_id = $1 AND name = $2)", + ) + .bind(repo_id) + .bind(&name) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if existing { + return Err(AppError::Conflict("tag already exists".into())); + } + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let tag = sqlx::query_as::<_, RepoTag>( + "INSERT INTO repo_tag (id, repo_id, name, target_commit_sha, tagger_id, message, signed, created_at) \ + VALUES ($1, $2, $3, $4, $5, $6, false, $7) RETURNING id, repo_id, name, target_commit_sha, tagger_id, message, signed, created_at", + ) + .bind(Uuid::now_v7()) + .bind(repo_id) + .bind(&name) + .bind(&target) + .bind(user_uid) + .bind(¶ms.message) + .bind(chrono::Utc::now()) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE repo_stats SET tags_count = tags_count + 1, updated_at = $1 WHERE repo_id = $2", + ) + .bind(chrono::Utc::now()) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(tag) + } + + pub async fn repo_delete_tag( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + tag_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query("DELETE FROM repo_tag WHERE id = $1 AND repo_id = $2") + .bind(tag_id) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "tag not found")?; + + sqlx::query( + "UPDATE repo_stats SET tags_count = GREATEST(tags_count - 1, 0), updated_at = $1 WHERE repo_id = $2", + ) + .bind(chrono::Utc::now()) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } +} diff --git a/service/repo/util.rs b/service/repo/util.rs new file mode 100644 index 0000000..a83877c --- /dev/null +++ b/service/repo/util.rs @@ -0,0 +1,3 @@ +pub use crate::service::util::{ + clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text, role_level, +}; diff --git a/service/repo/watches.rs b/service/repo/watches.rs new file mode 100644 index 0000000..553734b --- /dev/null +++ b/service/repo/watches.rs @@ -0,0 +1,166 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::SubscriptionLevel; +use crate::models::repos::RepoWatch; +use crate::service::RepoService; +use crate::session::Session; + +use super::util::clamp_limit_offset; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct WatchParams { + pub level: Option, +} + +impl RepoService { + pub async fn repo_watch( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: WatchParams, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_readable(user_uid, &repo).await?; + + let level = params + .level + .as_deref() + .and_then(|v| v.parse::().ok()) + .unwrap_or(SubscriptionLevel::Participating); + + if level == SubscriptionLevel::Unknown { + return Err(AppError::BadRequest("invalid watch level".into())); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let existing = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM repo_watch WHERE repo_id = $1 AND user_id = $2)", + ) + .bind(repo_id) + .bind(user_uid) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if existing { + sqlx::query("UPDATE repo_watch SET level = $1, updated_at = $2 WHERE repo_id = $3 AND user_id = $4") + .bind(level.to_string()) + .bind(now) + .bind(repo_id) + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } else { + sqlx::query("INSERT INTO repo_watch (id, repo_id, user_id, level, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $5)") + .bind(Uuid::now_v7()) + .bind(repo_id) + .bind(user_uid) + .bind(level.to_string()) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE repo_stats SET watchers_count = watchers_count + 1, updated_at = $1 WHERE repo_id = $2", + ) + .bind(now) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn repo_unwatch( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query("DELETE FROM repo_watch WHERE repo_id = $1 AND user_id = $2") + .bind(repo_id) + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + if result.rows_affected() > 0 { + sqlx::query( + "UPDATE repo_stats SET watchers_count = GREATEST(watchers_count - 1, 0), updated_at = $1 WHERE repo_id = $2", + ) + .bind(now) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn repo_watchers( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_readable(user_uid, &repo).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, RepoWatch>( + "SELECT id, repo_id, user_id, level, created_at, updated_at FROM repo_watch WHERE repo_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(repo_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } +} diff --git a/service/repo/webhooks.rs b/service/repo/webhooks.rs new file mode 100644 index 0000000..7e3359a --- /dev/null +++ b/service/repo/webhooks.rs @@ -0,0 +1,269 @@ +use serde::{Deserialize, Serialize}; +use std::net::IpAddr; +use url::Url; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::{EventType, Role}; +use crate::models::repos::RepoWebhook; +use crate::service::RepoService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, required_text}; + +/// Validate webhook URL for SSRF protection +fn validate_webhook_url(url_str: &str) -> Result<(), AppError> { + let url = Url::parse(url_str).map_err(|_| AppError::BadRequest("Invalid URL format".into()))?; + + // Only allow HTTPS + if url.scheme() != "https" { + return Err(AppError::BadRequest( + "Webhook URL must use HTTPS protocol".into(), + )); + } + + let host = url + .host_str() + .ok_or_else(|| AppError::BadRequest("URL must have a host".into()))?; + + // Reject IP addresses directly (require domain names) + if host.parse::().is_ok() { + return Err(AppError::BadRequest( + "Webhook URL must use a domain name, not an IP address".into(), + )); + } + + // Reject localhost and common local domains + let host_lower = host.to_lowercase(); + if host_lower == "localhost" + || host_lower.ends_with(".localhost") + || host_lower == "127.0.0.1" + || host_lower == "::1" + || host_lower == "0.0.0.0" + || host_lower.ends_with(".local") + || host_lower.ends_with(".internal") + { + return Err(AppError::BadRequest( + "Webhook URL cannot point to localhost or internal domains".into(), + )); + } + + // Reject metadata endpoints (AWS, GCP, Azure) + if host == "169.254.169.254" || host == "metadata.google.internal" { + return Err(AppError::BadRequest( + "Webhook URL cannot point to cloud metadata endpoints".into(), + )); + } + + // Note: Full DNS resolution and IP validation would require async DNS lookup + // and checking against private IP ranges. This is a basic validation layer. + // Production systems should implement async DNS resolution and IP validation + // at the webhook delivery layer. + + Ok(()) +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateWebhookParams { + pub url: String, + pub secret_ciphertext: Option, + pub events: Vec, + pub active: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateWebhookParams { + pub url: Option, + pub secret_ciphertext: Option, + pub events: Option>, + pub active: Option, +} + +impl RepoService { + pub async fn repo_webhooks( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, RepoWebhook>( + "SELECT id, repo_id, url, secret_ciphertext, events, active, last_delivery_status, last_delivery_at, created_by, created_at, updated_at FROM repo_webhook WHERE repo_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(repo_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn repo_create_webhook( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: CreateWebhookParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + let url = required_text(params.url, "url")?; + validate_webhook_url(&url)?; + if params.events.is_empty() { + return Err(AppError::BadRequest( + "at least one event is required".into(), + )); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, RepoWebhook>( + "INSERT INTO repo_webhook (id, repo_id, url, secret_ciphertext, events, active, \ + created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) RETURNING id, repo_id, url, secret_ciphertext, events, active, last_delivery_status, last_delivery_at, created_by, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(repo_id) + .bind(&url) + .bind(¶ms.secret_ciphertext) + .bind(¶ms.events) + .bind(params.active.unwrap_or(true)) + .bind(user_uid) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn repo_update_webhook( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + webhook_id: Uuid, + params: UpdateWebhookParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let current = sqlx::query_as::<_, RepoWebhook>( + "SELECT id, repo_id, url, secret_ciphertext, events, active, last_delivery_status, last_delivery_at, created_by, created_at, updated_at FROM repo_webhook WHERE id = $1 AND repo_id = $2", + ) + .bind(webhook_id) + .bind(repo_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("webhook not found".into()))?; + + let url = params + .url + .as_ref() + .map(|u| u.trim().to_string()) + .unwrap_or(current.url); + + // Validate URL if it was updated + if params.url.is_some() { + validate_webhook_url(&url)?; + } + + let active = params.active.unwrap_or(current.active); + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, RepoWebhook>( + "UPDATE repo_webhook SET url = $1, secret_ciphertext = $2, events = $3, \ + active = $4, updated_at = $5 WHERE id = $6 AND repo_id = $7 RETURNING id, repo_id, url, secret_ciphertext, events, active, last_delivery_status, last_delivery_at, created_by, created_at, updated_at", + ) + .bind(&url) + .bind(params.secret_ciphertext.or(current.secret_ciphertext)) + .bind(params.events.unwrap_or(current.events)) + .bind(active) + .bind(now) + .bind(webhook_id) + .bind(repo_id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn repo_delete_webhook( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + webhook_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query("DELETE FROM repo_webhook WHERE id = $1 AND repo_id = $2") + .bind(webhook_id) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "webhook not found")?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } +} diff --git a/service/user/account.rs b/service/user/account.rs new file mode 100644 index 0000000..8c0e500 --- /dev/null +++ b/service/user/account.rs @@ -0,0 +1,235 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::AppError; +use crate::models::common::Visibility; +use crate::models::users::User; +use crate::service::UserService; +use crate::session::Session; + +use super::util::{merge_optional_text, parse_enum}; +use crate::service::util::extract_storage_key_from_url; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateUserAccountParams { + pub username: Option, + pub display_name: Option, + pub bio: Option, + pub visibility: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UploadUserAvatarParams { + pub data: Vec, + pub content_type: Option, + pub file_name: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UserAvatarResponse { + pub avatar_url: String, + pub storage_key: String, +} + +impl UserService { + pub async fn user_account(&self, ctx: &Session) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + crate::models::users::User::find_by_id(self.ctx.db.reader(), user_uid) + .await + .map_err(AppError::Database)? + .ok_or(AppError::UserNotFound) + } + + pub async fn user_update_account( + &self, + ctx: &Session, + params: UpdateUserAccountParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let current = crate::models::users::User::find_by_id(self.ctx.db.reader(), user_uid) + .await + .map_err(AppError::Database)? + .ok_or(AppError::UserNotFound)?; + let username = params + .username + .map(|v| v.trim().to_string()) + .unwrap_or(current.username); + if username.is_empty() { + return Err(AppError::BadRequest("username is required".into())); + } + self.ensure_username_available(&username, user_uid).await?; + + let visibility = parse_visibility(¶ms.visibility, current.visibility)?; + let display_name = merge_optional_text(params.display_name, current.display_name); + let bio = merge_optional_text(params.bio, current.bio); + + sqlx::query_as::<_, User>( + "UPDATE \"user\" SET username = $1, display_name = $2, bio = $3, visibility = $4, \ + updated_at = $5 WHERE id = $6 AND deleted_at IS NULL \ + RETURNING id, username, display_name, avatar_url, bio, status, role, visibility, \ + is_active, is_bot, last_login_at, created_at, updated_at, deleted_at", + ) + .bind(&username) + .bind(&display_name) + .bind(&bio) + .bind(visibility) + .bind(chrono::Utc::now()) + .bind(user_uid) + .fetch_optional(self.ctx.db.writer()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::UserNotFound) + } + + pub async fn user_upload_avatar( + &self, + ctx: &Session, + params: UploadUserAvatarParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ext = avatar_extension(params.content_type.as_deref(), params.file_name.as_deref())?; + validate_avatar_size(params.data.len(), self.ctx.config.s3_max_upload_size()?)?; + + let current = crate::models::users::User::find_by_id(self.ctx.db.reader(), user_uid) + .await + .map_err(AppError::Database)? + .ok_or(AppError::UserNotFound)?; + let old_avatar_url = current.avatar_url.clone(); + + let storage_key = format!("users/{}/avatar/{}.{}", user_uid, uuid::Uuid::now_v7(), ext); + self.ctx.storage.put(&storage_key, params.data).await?; + let avatar_url = self.ctx.storage.public_url(&storage_key).ok_or_else(|| { + AppError::Config("APP_S3_PUBLIC_URL is required for avatar upload".into()) + })?; + + let result = sqlx::query( + "UPDATE \"user\" SET avatar_url = $1, updated_at = $2 \ + WHERE id = $3 AND deleted_at IS NULL", + ) + .bind(&avatar_url) + .bind(chrono::Utc::now()) + .bind(user_uid) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + if result.rows_affected() == 0 { + let _ = self.ctx.storage.delete(&storage_key).await; + return Err(AppError::UserNotFound); + } + + if let Some(old_url) = old_avatar_url + && let Some(old_key) = extract_storage_key_from_url(&old_url) + { + let _ = self.ctx.storage.delete(&old_key).await; + } + + Ok(UserAvatarResponse { + avatar_url, + storage_key, + }) + } + + pub async fn user_delete_account(&self, ctx: &Session) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + + let owned_workspace_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM workspace WHERE owner_id = $1 AND deleted_at IS NULL", + ) + .bind(user_uid) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + if owned_workspace_count > 0 { + return Err(AppError::BadRequest( + "transfer or delete owned workspaces before deleting the account".into(), + )); + } + + let owned_repo_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM repo WHERE owner_id = $1 AND deleted_at IS NULL", + ) + .bind(user_uid) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + if owned_repo_count > 0 { + return Err(AppError::BadRequest( + "transfer or delete owned repos before deleting the account".into(), + )); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + + for statement in [ + "DELETE FROM user_personal_access_token WHERE user_id = $1", + "DELETE FROM user_security_log WHERE user_id = $1", + "DELETE FROM user_session WHERE user_id = $1", + "DELETE FROM user_device WHERE user_id = $1", + "DELETE FROM user_oauth WHERE user_id = $1", + "DELETE FROM user_ssh_key WHERE user_id = $1", + "DELETE FROM user_gpg_key WHERE user_id = $1", + "DELETE FROM user_2fa WHERE user_id = $1", + "DELETE FROM user_notify_setting WHERE user_id = $1", + "DELETE FROM user_appearance WHERE user_id = $1", + "DELETE FROM user_profile WHERE user_id = $1", + "DELETE FROM user_mail WHERE user_id = $1", + "DELETE FROM workspace_member WHERE user_id = $1", + "DELETE FROM repo_member WHERE user_id = $1", + ] { + sqlx::query(statement) + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } + + let result = sqlx::query( + "UPDATE \"user\" SET deleted_at = $1, is_active = false, status = 'deleted', updated_at = $1 WHERE id = $2 AND deleted_at IS NULL", + ) + .bind(now) + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + if result.rows_affected() == 0 { + return Err(AppError::UserNotFound); + } + + txn.commit().await.map_err(|_| AppError::TxnError)?; + ctx.clear(); + Ok(()) + } + + async fn ensure_username_available( + &self, + username: &str, + user_uid: uuid::Uuid, + ) -> Result<(), AppError> { + let exists = sqlx::query( + "SELECT id FROM \"user\" WHERE lower(username) = lower($1) \ + AND id <> $2 AND deleted_at IS NULL LIMIT 1", + ) + .bind(username) + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + if exists.is_some() { + return Err(AppError::AccountAlreadyExists); + } + Ok(()) + } +} + +fn parse_visibility(next: &Option, current: Visibility) -> Result { + parse_enum(next.clone(), current, Visibility::Unknown, "visibility") +} + +use crate::service::util::{avatar_extension, validate_avatar_size}; diff --git a/service/user/appearance.rs b/service/user/appearance.rs new file mode 100644 index 0000000..4bdaac7 --- /dev/null +++ b/service/user/appearance.rs @@ -0,0 +1,107 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::AppError; +use crate::models::common::{ColorScheme, Density, FontSize, Theme}; +use crate::models::users::UserAppearance; +use crate::service::UserService; +use crate::session::Session; + +use super::util::{merge_optional_text, parse_enum}; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateUserAppearanceParams { + pub theme: Option, + pub color_scheme: Option, + pub density: Option, + pub font_size: Option, + pub editor_theme: Option, + pub markdown_preview: Option, + pub reduced_motion: Option, +} + +impl UserService { + pub async fn user_appearance(&self, ctx: &Session) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + self.ensure_user_appearance(user_uid).await + } + + pub async fn user_update_appearance( + &self, + ctx: &Session, + params: UpdateUserAppearanceParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let current = self.ensure_user_appearance(user_uid).await?; + let theme = parse_enum(params.theme, current.theme, Theme::Unknown, "theme")?; + let color_scheme = parse_enum( + params.color_scheme, + current.color_scheme, + ColorScheme::Unknown, + "color_scheme", + )?; + let density = parse_enum(params.density, current.density, Density::Unknown, "density")?; + let font_size = parse_enum( + params.font_size, + current.font_size, + FontSize::Unknown, + "font_size", + )?; + + sqlx::query_as::<_, UserAppearance>( + "UPDATE user_appearance SET theme = $1, color_scheme = $2, density = $3, font_size = $4, \ + editor_theme = $5, markdown_preview = $6, reduced_motion = $7, updated_at = $8 \ + WHERE user_id = $9 RETURNING user_id, theme, color_scheme, density, font_size, \ + editor_theme, markdown_preview, reduced_motion, created_at, updated_at", + ) + .bind(theme) + .bind(color_scheme) + .bind(density) + .bind(font_size) + .bind(merge_optional_text(params.editor_theme, current.editor_theme)) + .bind(params.markdown_preview.unwrap_or(current.markdown_preview)) + .bind(params.reduced_motion.unwrap_or(current.reduced_motion)) + .bind(chrono::Utc::now()) + .bind(user_uid) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database) + } + + async fn ensure_user_appearance( + &self, + user_uid: uuid::Uuid, + ) -> Result { + if let Some(appearance) = self.find_user_appearance(user_uid).await? { + return Ok(appearance); + } + let now = chrono::Utc::now(); + sqlx::query( + "INSERT INTO user_appearance (user_id, theme, color_scheme, density, font_size, \ + markdown_preview, reduced_motion, created_at, updated_at) \ + VALUES ($1, 'system', 'system', 'comfortable', 'medium', true, false, $2, $2) ON CONFLICT (user_id) DO NOTHING", + ) + .bind(user_uid) + .bind(now) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + self.find_user_appearance(user_uid) + .await? + .ok_or(AppError::UserNotFound) + } + + async fn find_user_appearance( + &self, + user_uid: uuid::Uuid, + ) -> Result, AppError> { + sqlx::query_as::<_, UserAppearance>( + "SELECT user_id, theme, color_scheme, density, font_size, editor_theme, \ + markdown_preview, reduced_motion, created_at, updated_at \ + FROM user_appearance WHERE user_id = $1", + ) + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } +} diff --git a/service/user/keys.rs b/service/user/keys.rs new file mode 100644 index 0000000..67e543e --- /dev/null +++ b/service/user/keys.rs @@ -0,0 +1,232 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::KeyType; +use crate::models::users::{UserGpgKey, UserSshKey}; +use crate::service::UserService; +use crate::session::Session; + +use super::util::{ensure_affected, required_text}; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct AddSshKeyParams { + pub title: String, + pub public_key: String, + pub key_type: String, + pub expires_at: Option>, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct AddGpgKeyParams { + pub public_key: String, + pub key_id: String, + pub primary_email: Option, + pub expires_at: Option>, +} + +impl UserService { + pub async fn user_ssh_keys(&self, ctx: &Session) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + sqlx::query_as::<_, UserSshKey>( + "SELECT id, user_id, title, public_key, fingerprint_sha256, key_type, last_used_at, \ + expires_at, revoked_at, created_at, updated_at FROM user_ssh_key \ + WHERE user_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC", + ) + .bind(user_uid) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn user_add_ssh_key( + &self, + ctx: &Session, + params: AddSshKeyParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let title = required_text(params.title, "title")?; + let public_key = required_text(params.public_key, "public_key")?; + let key_type = parse_key_type(¶ms.key_type)?; + let fingerprint = ssh_fingerprint(&public_key, key_type)?; + let now = chrono::Utc::now(); + + let existing = sqlx::query( + "SELECT id FROM user_ssh_key WHERE fingerprint_sha256 = $1 AND user_id = $2 AND revoked_at IS NULL LIMIT 1", + ) + .bind(&fingerprint) + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if existing.is_some() { + return Err(AppError::Conflict("SSH key already exists".into())); + } + + sqlx::query_as::<_, UserSshKey>( + "INSERT INTO user_ssh_key (id, user_id, title, public_key, fingerprint_sha256, key_type, \ + expires_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \ + RETURNING id, user_id, title, public_key, fingerprint_sha256, key_type, last_used_at, \ + expires_at, revoked_at, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(user_uid) + .bind(title) + .bind(&public_key) + .bind(fingerprint) + .bind(key_type) + .bind(params.expires_at) + .bind(now) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database) + } + + pub async fn user_delete_ssh_key(&self, ctx: &Session, key_uid: Uuid) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let result = sqlx::query( + "UPDATE user_ssh_key SET revoked_at = $1, updated_at = $1 \ + WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL", + ) + .bind(chrono::Utc::now()) + .bind(key_uid) + .bind(user_uid) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "key not found") + } + + pub async fn user_gpg_keys(&self, ctx: &Session) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + sqlx::query_as::<_, UserGpgKey>( + "SELECT id, user_id, key_id, public_key, fingerprint, primary_email, expires_at, \ + verified_at, revoked_at, created_at, updated_at FROM user_gpg_key \ + WHERE user_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC", + ) + .bind(user_uid) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn user_add_gpg_key( + &self, + ctx: &Session, + params: AddGpgKeyParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let public_key = required_text(params.public_key, "public_key")?; + let key_id = required_text(params.key_id, "key_id")?; + let primary_email = params + .primary_email + .map(|v| v.trim().to_lowercase()) + .filter(|v| !v.is_empty()); + let fingerprint = gpg_fingerprint(&public_key)?; + let now = chrono::Utc::now(); + + let existing = sqlx::query( + "SELECT id FROM user_gpg_key WHERE fingerprint = $1 AND user_id = $2 AND revoked_at IS NULL LIMIT 1", + ) + .bind(&fingerprint) + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if existing.is_some() { + return Err(AppError::Conflict("GPG key already exists".into())); + } + + sqlx::query_as::<_, UserGpgKey>( + "INSERT INTO user_gpg_key (id, user_id, key_id, public_key, fingerprint, primary_email, \ + expires_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \ + RETURNING id, user_id, key_id, public_key, fingerprint, primary_email, expires_at, \ + verified_at, revoked_at, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(user_uid) + .bind(key_id) + .bind(&public_key) + .bind(&fingerprint) + .bind(primary_email) + .bind(params.expires_at) + .bind(now) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database) + } + + pub async fn user_delete_gpg_key(&self, ctx: &Session, key_uid: Uuid) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let result = sqlx::query( + "UPDATE user_gpg_key SET revoked_at = $1, updated_at = $1 \ + WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL", + ) + .bind(chrono::Utc::now()) + .bind(key_uid) + .bind(user_uid) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "key not found") + } +} + +fn parse_key_type(value: &str) -> Result { + use crate::models::common::KeyType; + let key_type = value + .trim() + .parse::() + .map_err(|_| AppError::BadRequest("invalid key_type".into()))?; + if key_type == KeyType::Unknown { + return Err(AppError::BadRequest("invalid key_type".into())); + } + Ok(key_type) +} + +fn ssh_fingerprint(public_key: &str, expected_type: KeyType) -> Result { + use base64::Engine; + + let parts: Vec<&str> = public_key.split_whitespace().collect(); + if parts.len() < 2 { + return Err(AppError::BadRequest("invalid SSH public key format".into())); + } + + let actual_type = ssh_key_type(parts[0])?; + if actual_type != expected_type { + return Err(AppError::BadRequest( + "key_type does not match SSH public key".into(), + )); + } + + let decoded = base64::engine::general_purpose::STANDARD + .decode(parts[1]) + .map_err(|_| AppError::BadRequest("invalid SSH public key data".into()))?; + if decoded.is_empty() { + return Err(AppError::BadRequest("invalid SSH public key data".into())); + } + + Ok(super::util::sha256_hex(&decoded)) +} + +fn ssh_key_type(value: &str) -> Result { + match value { + "ssh-rsa" => Ok(KeyType::Rsa), + "ssh-ed25519" => Ok(KeyType::Ed25519), + v if v.starts_with("ecdsa-sha2-") => Ok(KeyType::Ecdsa), + "ssh-dss" => Ok(KeyType::Dsa), + _ => Err(AppError::BadRequest("unsupported SSH key type".into())), + } +} + +fn gpg_fingerprint(public_key: &str) -> Result { + let key = public_key.trim(); + if !key.starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----") + || !key.contains("-----END PGP PUBLIC KEY BLOCK-----") + { + return Err(AppError::BadRequest("invalid GPG public key format".into())); + } + Ok(super::util::sha256_hex(key.as_bytes())) +} diff --git a/service/user/mod.rs b/service/user/mod.rs new file mode 100644 index 0000000..37563c4 --- /dev/null +++ b/service/user/mod.rs @@ -0,0 +1,7 @@ +pub mod account; +pub mod appearance; +pub mod keys; +pub mod notify; +pub mod profile; +pub mod security; +pub mod util; diff --git a/service/user/notify.rs b/service/user/notify.rs new file mode 100644 index 0000000..1855284 --- /dev/null +++ b/service/user/notify.rs @@ -0,0 +1,122 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::AppError; +use crate::models::common::DigestFrequency; +use crate::models::users::UserNotifySetting; +use crate::service::UserService; +use crate::session::Session; + +use super::util::parse_enum; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateUserNotifySettingParams { + pub email_notifications: Option, + pub web_notifications: Option, + pub mention_notifications: Option, + pub review_notifications: Option, + pub security_notifications: Option, + pub marketing_emails: Option, + pub digest_frequency: Option, +} + +impl UserService { + pub async fn user_notify_setting(&self, ctx: &Session) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + self.ensure_user_notify_setting(user_uid).await + } + + pub async fn user_update_notify_setting( + &self, + ctx: &Session, + params: UpdateUserNotifySettingParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let current = self.ensure_user_notify_setting(user_uid).await?; + let digest_frequency = parse_enum( + params.digest_frequency, + current.digest_frequency, + DigestFrequency::Unknown, + "digest_frequency", + )?; + + sqlx::query_as::<_, UserNotifySetting>( + "UPDATE user_notify_setting SET email_notifications = $1, web_notifications = $2, \ + mention_notifications = $3, review_notifications = $4, security_notifications = $5, \ + marketing_emails = $6, digest_frequency = $7, updated_at = $8 WHERE user_id = $9 \ + RETURNING user_id, email_notifications, web_notifications, mention_notifications, \ + review_notifications, security_notifications, marketing_emails, digest_frequency, \ + created_at, updated_at", + ) + .bind( + params + .email_notifications + .unwrap_or(current.email_notifications), + ) + .bind( + params + .web_notifications + .unwrap_or(current.web_notifications), + ) + .bind( + params + .mention_notifications + .unwrap_or(current.mention_notifications), + ) + .bind( + params + .review_notifications + .unwrap_or(current.review_notifications), + ) + .bind( + params + .security_notifications + .unwrap_or(current.security_notifications), + ) + .bind(params.marketing_emails.unwrap_or(current.marketing_emails)) + .bind(digest_frequency) + .bind(chrono::Utc::now()) + .bind(user_uid) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database) + } + + async fn ensure_user_notify_setting( + &self, + user_uid: uuid::Uuid, + ) -> Result { + if let Some(setting) = self.find_user_notify_setting(user_uid).await? { + return Ok(setting); + } + let now = chrono::Utc::now(); + sqlx::query( + "INSERT INTO user_notify_setting (user_id, email_notifications, web_notifications, \ + mention_notifications, review_notifications, security_notifications, marketing_emails, \ + digest_frequency, created_at, updated_at) \ + VALUES ($1, true, true, true, true, true, false, 'realtime', $2, $2) ON CONFLICT (user_id) DO NOTHING", + ) + .bind(user_uid) + .bind(now) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + self.find_user_notify_setting(user_uid) + .await? + .ok_or(AppError::UserNotFound) + } + + async fn find_user_notify_setting( + &self, + user_uid: uuid::Uuid, + ) -> Result, AppError> { + sqlx::query_as::<_, UserNotifySetting>( + "SELECT user_id, email_notifications, web_notifications, mention_notifications, \ + review_notifications, security_notifications, marketing_emails, digest_frequency, \ + created_at, updated_at FROM user_notify_setting WHERE user_id = $1", + ) + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } +} diff --git a/service/user/profile.rs b/service/user/profile.rs new file mode 100644 index 0000000..b87cf46 --- /dev/null +++ b/service/user/profile.rs @@ -0,0 +1,90 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::AppError; +use crate::models::users::UserProfile; +use crate::service::UserService; +use crate::session::Session; + +use super::util::merge_optional_text; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateUserProfileParams { + pub full_name: Option, + pub company: Option, + pub location: Option, + pub website_url: Option, + pub twitter_username: Option, + pub timezone: Option, + pub language: Option, + pub profile_readme: Option, +} + +impl UserService { + pub async fn user_profile(&self, ctx: &Session) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + self.ensure_user_profile(user_uid).await + } + + pub async fn user_update_profile( + &self, + ctx: &Session, + params: UpdateUserProfileParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let current = self.ensure_user_profile(user_uid).await?; + let now = chrono::Utc::now(); + + sqlx::query_as::<_, UserProfile>( + "UPDATE user_profile SET full_name = $1, company = $2, location = $3, website_url = $4, \ + twitter_username = $5, timezone = $6, language = $7, profile_readme = $8, updated_at = $9 \ + WHERE user_id = $10 RETURNING user_id, full_name, company, location, website_url, \ + twitter_username, timezone, language, profile_readme, created_at, updated_at", + ) + .bind(merge_optional_text(params.full_name, current.full_name)) + .bind(merge_optional_text(params.company, current.company)) + .bind(merge_optional_text(params.location, current.location)) + .bind(merge_optional_text(params.website_url, current.website_url)) + .bind(merge_optional_text(params.twitter_username, current.twitter_username)) + .bind(merge_optional_text(params.timezone, current.timezone)) + .bind(merge_optional_text(params.language, current.language)) + .bind(merge_optional_text(params.profile_readme, current.profile_readme)) + .bind(now) + .bind(user_uid) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database) + } + + async fn ensure_user_profile(&self, user_uid: uuid::Uuid) -> Result { + if let Some(profile) = self.find_user_profile(user_uid).await? { + return Ok(profile); + } + let now = chrono::Utc::now(); + sqlx::query( + "INSERT INTO user_profile (user_id, created_at, updated_at) VALUES ($1, $2, $2) ON CONFLICT (user_id) DO NOTHING", + ) + .bind(user_uid) + .bind(now) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + self.find_user_profile(user_uid) + .await? + .ok_or(AppError::UserNotFound) + } + + async fn find_user_profile( + &self, + user_uid: uuid::Uuid, + ) -> Result, AppError> { + sqlx::query_as::<_, UserProfile>( + "SELECT user_id, full_name, company, location, website_url, twitter_username, \ + timezone, language, profile_readme, created_at, updated_at \ + FROM user_profile WHERE user_id = $1", + ) + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } +} diff --git a/service/user/security.rs b/service/user/security.rs new file mode 100644 index 0000000..8c1da22 --- /dev/null +++ b/service/user/security.rs @@ -0,0 +1,331 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::{EventType, JsonValue, Provider, Scope}; +use crate::models::users::{UserDevice, UserSecurityLog}; +use crate::service::UserService; +use crate::session::Session; + +use super::util::ensure_affected; + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct UserSessionInfo { + pub id: Uuid, + pub ip_address: Option, + pub user_agent: Option, + pub last_active_at: DateTime, + pub expires_at: DateTime, + pub revoked_at: Option>, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct UserOAuthInfo { + pub id: Uuid, + pub provider: Provider, + pub provider_user_id: String, + pub provider_username: Option, + pub provider_email: Option, + pub token_expires_at: Option>, + pub linked_at: DateTime, + pub last_used_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct UserPersonalAccessTokenInfo { + pub id: Uuid, + pub name: String, + pub scopes: Vec, + pub last_used_at: Option>, + pub expires_at: Option>, + pub revoked_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +struct UserSessionRow { + id: Uuid, + ip_address: Option, + user_agent: Option, + last_active_at: DateTime, + expires_at: DateTime, + revoked_at: Option>, + created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +struct UserOAuthRow { + id: Uuid, + provider: Provider, + provider_user_id: String, + provider_username: Option, + provider_email: Option, + token_expires_at: Option>, + linked_at: DateTime, + last_used_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +struct UserPersonalAccessTokenRow { + id: Uuid, + name: String, + scopes: Vec, + last_used_at: Option>, + expires_at: Option>, + revoked_at: Option>, + created_at: DateTime, + updated_at: DateTime, +} + +impl UserService { + pub async fn user_devices(&self, ctx: &Session) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + sqlx::query_as::<_, UserDevice>( + "SELECT id, user_id, device_name, device_type, fingerprint, ip_address, user_agent, \ + trusted, last_seen_at, created_at, updated_at FROM user_device \ + WHERE user_id = $1 ORDER BY last_seen_at DESC NULLS LAST, created_at DESC", + ) + .bind(user_uid) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn user_delete_device( + &self, + ctx: &Session, + device_uid: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let result = sqlx::query("DELETE FROM user_device WHERE id = $1 AND user_id = $2") + .bind(device_uid) + .bind(user_uid) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "device not found") + } + + pub async fn user_sessions( + &self, + ctx: &Session, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let limit = limit.clamp(1, 100); + let offset = offset.max(0); + let rows = sqlx::query_as::<_, UserSessionRow>( + "SELECT id, ip_address, user_agent, last_active_at, expires_at, revoked_at, created_at \ + FROM user_session WHERE user_id = $1 ORDER BY last_active_at DESC LIMIT $2 OFFSET $3", + ) + .bind(user_uid) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + Ok(rows.into_iter().map(Into::into).collect()) + } + + pub async fn user_revoke_session( + &self, + ctx: &Session, + session_uid: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let result = sqlx::query( + "UPDATE user_session SET revoked_at = $1 \ + WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL", + ) + .bind(chrono::Utc::now()) + .bind(session_uid) + .bind(user_uid) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "session not found") + } + + pub async fn user_oauth_accounts(&self, ctx: &Session) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let rows = sqlx::query_as::<_, UserOAuthRow>( + "SELECT id, provider, provider_user_id, provider_username, provider_email, \ + token_expires_at, linked_at, last_used_at FROM user_oauth \ + WHERE user_id = $1 ORDER BY linked_at DESC", + ) + .bind(user_uid) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + Ok(rows.into_iter().map(Into::into).collect()) + } + + pub async fn user_unlink_oauth(&self, ctx: &Session, oauth_uid: Uuid) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + + let has_password: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_password WHERE user_id = $1)") + .bind(user_uid) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let oauth_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM user_oauth WHERE user_id = $1") + .bind(user_uid) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if !has_password && oauth_count <= 1 { + return Err(AppError::BadRequest( + "cannot unlink the last login method; please set a password first".into(), + )); + } + + let result = sqlx::query("DELETE FROM user_oauth WHERE id = $1 AND user_id = $2") + .bind(oauth_uid) + .bind(user_uid) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "oauth account not found") + } + + pub async fn user_security_logs( + &self, + ctx: &Session, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let limit = limit.clamp(1, 100); + let offset = offset.max(0); + sqlx::query_as::<_, UserSecurityLog>( + "SELECT id, user_id, event_type, description, ip_address, user_agent, metadata, created_at \ + FROM user_security_log WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(user_uid) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn user_log_security_event( + &self, + user_uid: Uuid, + event_type: EventType, + description: Option, + ip_address: Option, + user_agent: Option, + metadata: Option, + ) -> Result<(), AppError> { + sqlx::query( + "INSERT INTO user_security_log (id, user_id, event_type, description, ip_address, user_agent, metadata, created_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + ) + .bind(Uuid::now_v7()) + .bind(user_uid) + .bind(event_type) + .bind(description) + .bind(ip_address) + .bind(user_agent) + .bind(metadata) + .bind(chrono::Utc::now()) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + Ok(()) + } + + pub async fn user_personal_access_tokens( + &self, + ctx: &Session, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let limit = limit.clamp(1, 100); + let offset = offset.max(0); + let rows = sqlx::query_as::<_, UserPersonalAccessTokenRow>( + "SELECT id, name, scopes, last_used_at, expires_at, revoked_at, created_at, updated_at \ + FROM user_personal_access_token WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(user_uid) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + Ok(rows.into_iter().map(Into::into).collect()) + } + + pub async fn user_revoke_personal_access_token( + &self, + ctx: &Session, + token_uid: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let result = sqlx::query( + "UPDATE user_personal_access_token SET revoked_at = $1, updated_at = $1 \ + WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL", + ) + .bind(chrono::Utc::now()) + .bind(token_uid) + .bind(user_uid) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "token not found") + } +} + +impl From for UserSessionInfo { + fn from(row: UserSessionRow) -> Self { + Self { + id: row.id, + ip_address: row.ip_address, + user_agent: row.user_agent, + last_active_at: row.last_active_at, + expires_at: row.expires_at, + revoked_at: row.revoked_at, + created_at: row.created_at, + } + } +} + +impl From for UserOAuthInfo { + fn from(row: UserOAuthRow) -> Self { + Self { + id: row.id, + provider: row.provider, + provider_user_id: row.provider_user_id, + provider_username: row.provider_username, + provider_email: row.provider_email, + token_expires_at: row.token_expires_at, + linked_at: row.linked_at, + last_used_at: row.last_used_at, + } + } +} + +impl From for UserPersonalAccessTokenInfo { + fn from(row: UserPersonalAccessTokenRow) -> Self { + Self { + id: row.id, + name: row.name, + scopes: row.scopes, + last_used_at: row.last_used_at, + expires_at: row.expires_at, + revoked_at: row.revoked_at, + created_at: row.created_at, + updated_at: row.updated_at, + } + } +} diff --git a/service/user/util.rs b/service/user/util.rs new file mode 100644 index 0000000..cf17a13 --- /dev/null +++ b/service/user/util.rs @@ -0,0 +1,3 @@ +pub use crate::service::util::{ + ensure_affected, merge_optional_text, parse_enum, required_text, sha256_hex, +}; diff --git a/service/util.rs b/service/util.rs new file mode 100644 index 0000000..5183b09 --- /dev/null +++ b/service/util.rs @@ -0,0 +1,154 @@ +use crate::error::AppError; +use crate::models::common::Role; + +pub fn merge_optional_text(next: Option, current: Option) -> Option { + next.map(|v| { + let value = v.trim().to_string(); + if value.is_empty() { None } else { Some(value) } + }) + .unwrap_or(current) +} + +pub fn ensure_affected(rows_affected: u64, not_found: &str) -> Result<(), AppError> { + if rows_affected == 0 { + Err(AppError::NotFound(not_found.into())) + } else { + Ok(()) + } +} + +pub fn parse_enum( + next: Option, + current: T, + unknown: T, + name: &str, +) -> Result +where + T: std::str::FromStr + PartialEq, +{ + let Some(value) = next else { + return Ok(current); + }; + let parsed = value + .trim() + .parse::() + .map_err(|_| AppError::BadRequest(format!("invalid {name}")))?; + if parsed == unknown { + return Err(AppError::BadRequest(format!("invalid {name}"))); + } + Ok(parsed) +} + +pub fn required_text(value: String, field: &str) -> Result { + let value = value.trim().to_string(); + if value.is_empty() { + return Err(AppError::BadRequest(format!("{field} is required"))); + } + Ok(value) +} + +pub fn clamp_limit_offset(limit: i64, offset: i64) -> (i64, i64) { + (limit.clamp(1, 100), offset.max(0)) +} + +pub fn role_level(role: Role) -> i32 { + match role { + Role::Owner => 100, + Role::Admin => 90, + Role::Maintainer => 70, + Role::Member => 50, + Role::Contributor => 30, + Role::Viewer => 10, + Role::Guest => 5, + _ => 0, + } +} + +pub fn constant_time_eq(a: &str, b: &str) -> bool { + if a.len() != b.len() { + return false; + } + a.bytes() + .zip(b.bytes()) + .fold(0, |acc, (x, y)| acc | (x ^ y)) + == 0 +} + +pub fn sha256_hex(data: &[u8]) -> String { + use sha2::Digest; + sha2::Sha256::digest(data) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +pub fn extract_storage_key_from_url(url: &str) -> Option { + let path = url.split_once("://").map(|(_, rest)| rest)?; + let path = path.split_once('/').map(|(_, rest)| rest).unwrap_or(path); + if path.is_empty() { + None + } else { + Some(path.to_string()) + } +} + +pub fn avatar_extension( + content_type: Option<&str>, + file_name: Option<&str>, +) -> Result<&'static str, AppError> { + if let Some(ct) = content_type.map(str::trim).filter(|v| !v.is_empty()) { + return match ct.to_ascii_lowercase().as_str() { + "image/png" => Ok("png"), + "image/jpeg" | "image/jpg" => Ok("jpg"), + "image/webp" => Ok("webp"), + "image/gif" => Ok("gif"), + _ => Err(AppError::BadRequest( + "unsupported avatar content type".into(), + )), + }; + } + let Some(file_name) = file_name else { + return Err(AppError::BadRequest( + "avatar content type is required".into(), + )); + }; + match file_name + .rsplit('.') + .next() + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "png" => Ok("png"), + "jpg" | "jpeg" => Ok("jpg"), + "webp" => Ok("webp"), + "gif" => Ok("gif"), + _ => Err(AppError::BadRequest("unsupported avatar file type".into())), + } +} + +pub fn validate_avatar_size(size: usize, configured_max_size: u64) -> Result<(), AppError> { + const MAX_AVATAR_SIZE: u64 = 5 * 1024 * 1024; + const MIN_AVATAR_SIZE: u64 = 1024; + if size == 0 { + return Err(AppError::BadRequest("avatar is empty".into())); + } + let max_size = configured_max_size.clamp(MIN_AVATAR_SIZE, MAX_AVATAR_SIZE) as usize; + if size > max_size { + return Err(AppError::BadRequest("avatar is too large".into())); + } + Ok(()) +} + +pub fn validate_password_strength(password: &str) -> Result<(), AppError> { + if password.len() < 8 { + return Err(AppError::PasswordTooWeak); + } + if !password.chars().any(|c| c.is_uppercase()) + || !password.chars().any(|c| c.is_lowercase()) + || !password.chars().any(|c| c.is_numeric()) + { + return Err(AppError::PasswordTooWeak); + } + Ok(()) +} diff --git a/service/wiki/core.rs b/service/wiki/core.rs new file mode 100644 index 0000000..220bbb6 --- /dev/null +++ b/service/wiki/core.rs @@ -0,0 +1,348 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::wiki::WikiPage; +use crate::service::RepoService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, required_text}; + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateWikiPageParams { + pub title: String, + pub content: String, +} + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateWikiPageParams { + pub title: Option, + pub content: Option, + pub commit_message: Option, +} + +impl RepoService { + /// 列出仓库的所有 wiki 页面 + pub async fn wiki_list_pages( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + search: Option, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + if let Some(query) = search { + let pattern = format!("%{}%", query); + sqlx::query_as::<_, WikiPage>( + "SELECT id, repo_id, slug, title, content, author_id, last_editor_id, version, \ + created_at, updated_at, deleted_at \ + FROM wiki_page WHERE repo_id = $1 AND deleted_at IS NULL \ + AND (title ILIKE $2 OR content ILIKE $2) \ + ORDER BY updated_at DESC LIMIT $3 OFFSET $4", + ) + .bind(repo.id) + .bind(&pattern) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } else { + sqlx::query_as::<_, WikiPage>( + "SELECT id, repo_id, slug, title, content, author_id, last_editor_id, version, \ + created_at, updated_at, deleted_at \ + FROM wiki_page WHERE repo_id = $1 AND deleted_at IS NULL \ + ORDER BY updated_at DESC LIMIT $2 OFFSET $3", + ) + .bind(repo.id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + } + + /// 获取单个 wiki 页面 + pub async fn wiki_get_page( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + slug: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + + sqlx::query_as::<_, WikiPage>( + "SELECT id, repo_id, slug, title, content, author_id, last_editor_id, version, \ + created_at, updated_at, deleted_at \ + FROM wiki_page WHERE repo_id = $1 AND slug = $2 AND deleted_at IS NULL", + ) + .bind(repo.id) + .bind(slug) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or_else(|| AppError::NotFound("Wiki page not found".into())) + } + + /// 创建 wiki 页面 + pub async fn wiki_create_page( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: CreateWikiPageParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + + let title = required_text(params.title, "title")?; + let content = required_text(params.content, "content")?; + let slug = self.generate_wiki_slug(repo.id, &title).await?; + let now = chrono::Utc::now(); + let page_id = Uuid::now_v7(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let page = sqlx::query_as::<_, WikiPage>( + "INSERT INTO wiki_page (id, repo_id, slug, title, content, author_id, last_editor_id, version, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $6, 1, $7, $7) \ + RETURNING id, repo_id, slug, title, content, author_id, last_editor_id, version, created_at, updated_at, deleted_at", + ) + .bind(page_id) + .bind(repo.id) + .bind(&slug) + .bind(&title) + .bind(&content) + .bind(user_uid) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO wiki_page_revision (id, page_id, version, title, content, editor_id, commit_message, created_at) \ + VALUES ($1, $2, 1, $3, $4, $5, 'Initial creation', $6)", + ) + .bind(Uuid::now_v7()) + .bind(page_id) + .bind(&title) + .bind(&content) + .bind(user_uid) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(page) + } + + /// 更新 wiki 页面 + pub async fn wiki_update_page( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + slug: &str, + params: UpdateWikiPageParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + + let page = self.wiki_get_page(ctx, wk_name, repo_name, slug).await?; + let new_title = params.title.unwrap_or(page.title.clone()); + let new_content = params.content.unwrap_or(page.content.clone()); + let new_version = page.version + 1; + let commit_message = params + .commit_message + .unwrap_or_else(|| "Updated page".into()); + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let updated = sqlx::query_as::<_, WikiPage>( + "UPDATE wiki_page SET title = $1, content = $2, last_editor_id = $3, version = $4, updated_at = $5 \ + WHERE id = $6 AND deleted_at IS NULL \ + RETURNING id, repo_id, slug, title, content, author_id, last_editor_id, version, created_at, updated_at, deleted_at", + ) + .bind(&new_title) + .bind(&new_content) + .bind(user_uid) + .bind(new_version) + .bind(now) + .bind(page.id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO wiki_page_revision (id, page_id, version, title, content, editor_id, commit_message, created_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + ) + .bind(Uuid::now_v7()) + .bind(page.id) + .bind(new_version) + .bind(&new_title) + .bind(&new_content) + .bind(user_uid) + .bind(&commit_message) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(updated) + } + + /// 删除 wiki 页面(软删除) + pub async fn wiki_delete_page( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + slug: &str, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let page = self.wiki_get_page(ctx, wk_name, repo_name, slug).await?; + let now = chrono::Utc::now(); + + let result = sqlx::query( + "UPDATE wiki_page SET deleted_at = $1 WHERE id = $2 AND deleted_at IS NULL", + ) + .bind(now) + .bind(page.id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + ensure_affected(result.rows_affected(), "wiki page not found") + } + + /// 回滚到历史版本 + pub async fn wiki_revert_to_version( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + slug: &str, + target_version: i32, + commit_message: Option, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + + let page = self.wiki_get_page(ctx, wk_name, repo_name, slug).await?; + + let revision = sqlx::query_as::<_, crate::models::wiki::WikiPageRevision>( + "SELECT id, page_id, version, title, content, editor_id, commit_message, created_at \ + FROM wiki_page_revision WHERE page_id = $1 AND version = $2", + ) + .bind(page.id) + .bind(target_version) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or_else(|| AppError::NotFound("Revision not found".into()))?; + + let msg = + commit_message.unwrap_or_else(|| format!("Reverted to version {}", target_version)); + + self.wiki_update_page( + ctx, + wk_name, + repo_name, + slug, + UpdateWikiPageParams { + title: Some(revision.title), + content: Some(revision.content), + commit_message: Some(msg), + }, + ) + .await + } + + fn generate_slug(title: &str) -> String { + title + .to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") + } + + async fn generate_wiki_slug(&self, repo_id: Uuid, title: &str) -> Result { + let base_slug = Self::generate_slug(title); + let mut slug = base_slug.clone(); + let mut counter = 1; + + loop { + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM wiki_page WHERE repo_id = $1 AND slug = $2)", + ) + .bind(repo_id) + .bind(&slug) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if !exists { + return Ok(slug); + } + + slug = format!("{}-{}", base_slug, counter); + counter += 1; + + if counter > 100 { + return Err(AppError::InternalServerError( + "Failed to generate unique slug".into(), + )); + } + } + } +} diff --git a/service/wiki/mod.rs b/service/wiki/mod.rs new file mode 100644 index 0000000..2c6fcbe --- /dev/null +++ b/service/wiki/mod.rs @@ -0,0 +1,3 @@ +pub mod core; +pub mod revisions; +pub mod util; diff --git a/service/wiki/revisions.rs b/service/wiki/revisions.rs new file mode 100644 index 0000000..dd726c0 --- /dev/null +++ b/service/wiki/revisions.rs @@ -0,0 +1,81 @@ +use crate::error::AppError; +use crate::models::wiki::WikiPageRevision; +use crate::service::RepoService; +use crate::session::Session; + +use super::util::clamp_limit_offset; + +impl RepoService { + /// 获取页面的修订历史 + pub async fn wiki_get_revisions( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + slug: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let page = self.wiki_get_page(ctx, wk_name, repo_name, slug).await?; + self.ensure_repo_readable(user_uid, &self.resolve_repo(wk_name, repo_name).await?) + .await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + sqlx::query_as::<_, WikiPageRevision>( + "SELECT id, page_id, version, title, content, editor_id, commit_message, created_at \ + FROM wiki_page_revision WHERE page_id = $1 ORDER BY version DESC LIMIT $2 OFFSET $3", + ) + .bind(page.id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + /// 获取特定版本的修订详情 + pub async fn wiki_get_revision( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + slug: &str, + version: i32, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let page = self.wiki_get_page(ctx, wk_name, repo_name, slug).await?; + self.ensure_repo_readable(user_uid, &self.resolve_repo(wk_name, repo_name).await?) + .await?; + + sqlx::query_as::<_, WikiPageRevision>( + "SELECT id, page_id, version, title, content, editor_id, commit_message, created_at \ + FROM wiki_page_revision WHERE page_id = $1 AND version = $2", + ) + .bind(page.id) + .bind(version) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or_else(|| AppError::NotFound("Revision not found".into())) + } + + /// 比较两个版本的差异 + pub async fn wiki_compare_revisions( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + slug: &str, + old_version: i32, + new_version: i32, + ) -> Result<(WikiPageRevision, WikiPageRevision), AppError> { + let old = self + .wiki_get_revision(ctx, wk_name, repo_name, slug, old_version) + .await?; + let new = self + .wiki_get_revision(ctx, wk_name, repo_name, slug, new_version) + .await?; + Ok((old, new)) + } +} diff --git a/service/wiki/util.rs b/service/wiki/util.rs new file mode 100644 index 0000000..967b48f --- /dev/null +++ b/service/wiki/util.rs @@ -0,0 +1 @@ +pub use crate::service::util::{clamp_limit_offset, ensure_affected, required_text}; diff --git a/service/workspace/approvals.rs b/service/workspace/approvals.rs new file mode 100644 index 0000000..9c3e166 --- /dev/null +++ b/service/workspace/approvals.rs @@ -0,0 +1,148 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::{RequestType, Role, Status}; +use crate::models::workspaces::WorkspacePendingApproval; +use crate::service::WorkspaceService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, parse_enum}; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct RequestApprovalParams { + pub request_type: String, + pub reason: Option, +} + +impl WorkspaceService { + pub async fn workspace_pending_approvals( + &self, + ctx: &Session, + workspace_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, WorkspacePendingApproval>( + "SELECT id, workspace_id, requester_id, request_type, status, reason, \ + reviewed_by, reviewed_at, expires_at, created_at, updated_at \ + FROM workspace_pending_approval WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(workspace_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn workspace_request_approval( + &self, + ctx: &Session, + workspace_id: Uuid, + params: RequestApprovalParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_readable(user_uid, &ws).await?; + + let request_type = parse_enum( + Some(params.request_type), + RequestType::Unknown, + RequestType::Unknown, + "request_type", + )?; + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, WorkspacePendingApproval>( + "INSERT INTO workspace_pending_approval (id, workspace_id, requester_id, request_type, status, \ + reason, expires_at, created_at, updated_at) VALUES ($1, $2, $3, $4, 'pending', $5, $6, $7, $7) \ + RETURNING id, workspace_id, requester_id, request_type, status, reason, \ + reviewed_by, reviewed_at, expires_at, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(workspace_id) + .bind(user_uid) + .bind(request_type) + .bind(params.reason) + .bind(now + chrono::Duration::days(30)) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn workspace_review_approval( + &self, + ctx: &Session, + workspace_id: Uuid, + approval_id: Uuid, + approved: bool, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner) + .await?; + + let status = if approved { + Status::Accepted + } else { + Status::Rejected + }; + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE workspace_pending_approval SET status = $1, reviewed_by = $2, reviewed_at = $3, updated_at = $3 \ + WHERE id = $4 AND workspace_id = $5 AND status = 'pending'", + ) + .bind(status.to_string()) + .bind(user_uid) + .bind(now) + .bind(approval_id) + .bind(workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected( + result.rows_affected(), + "approval not found or already reviewed", + )?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } +} diff --git a/service/workspace/audit.rs b/service/workspace/audit.rs new file mode 100644 index 0000000..f23fb8f --- /dev/null +++ b/service/workspace/audit.rs @@ -0,0 +1,69 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::{EventType, JsonValue, Role, TargetType}; +use crate::models::workspaces::WorkspaceAuditLog; +use crate::service::WorkspaceService; +use crate::session::Session; + +use super::util::clamp_limit_offset; + +impl WorkspaceService { + pub async fn workspace_audit_logs( + &self, + ctx: &Session, + workspace_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, WorkspaceAuditLog>( + "SELECT id, workspace_id, actor_id, action, target_type, target_id, \ + ip_address, user_agent, metadata, created_at FROM workspace_audit_log \ + WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(workspace_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + #[allow(clippy::too_many_arguments)] + pub async fn workspace_log_audit( + &self, + workspace_id: Uuid, + actor_id: Uuid, + action: EventType, + target_type: Option, + target_id: Option, + ip_address: Option, + user_agent: Option, + metadata: Option, + ) -> Result<(), AppError> { + sqlx::query( + "INSERT INTO workspace_audit_log (id, workspace_id, actor_id, action, target_type, \ + target_id, ip_address, user_agent, metadata, created_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + ) + .bind(Uuid::now_v7()) + .bind(workspace_id) + .bind(actor_id) + .bind(action) + .bind(target_type) + .bind(target_id) + .bind(ip_address) + .bind(user_agent) + .bind(metadata) + .bind(chrono::Utc::now()) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + Ok(()) + } +} diff --git a/service/workspace/billing.rs b/service/workspace/billing.rs new file mode 100644 index 0000000..541dc24 --- /dev/null +++ b/service/workspace/billing.rs @@ -0,0 +1,118 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::workspaces::WorkspaceBilling; +use crate::service::WorkspaceService; +use crate::session::Session; + +use super::util::merge_optional_text; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateBillingParams { + pub plan: Option, + pub billing_email: Option, + pub seats: Option, +} + +impl WorkspaceService { + pub async fn workspace_billing( + &self, + ctx: &Session, + workspace_id: Uuid, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner) + .await?; + self.ensure_workspace_billing(workspace_id).await + } + + pub async fn workspace_update_billing( + &self, + ctx: &Session, + workspace_id: Uuid, + params: UpdateBillingParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner) + .await?; + + let current = self.ensure_workspace_billing(workspace_id).await?; + let plan = params.plan.unwrap_or(current.plan.clone()); + let billing_email = merge_optional_text(params.billing_email, current.billing_email); + let seats = params.seats.unwrap_or(current.seats); + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, WorkspaceBilling>( + "UPDATE workspace_billing SET plan = $1, billing_email = $2, seats = $3, updated_at = $4 \ + WHERE workspace_id = $5 \ + RETURNING workspace_id, customer_id, subscription_id, plan, billing_email, status, \ + seats, trial_ends_at, current_period_start, current_period_end, canceled_at, \ + created_at, updated_at", + ) + .bind(&plan) + .bind(&billing_email) + .bind(seats) + .bind(now) + .bind(workspace_id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + async fn ensure_workspace_billing( + &self, + workspace_id: Uuid, + ) -> Result { + if let Some(billing) = self.find_workspace_billing(workspace_id).await? { + return Ok(billing); + } + let now = chrono::Utc::now(); + sqlx::query( + "INSERT INTO workspace_billing (workspace_id, plan, status, seats, created_at, updated_at) \ + VALUES ($1, 'free', 'active', 1, $2, $2) ON CONFLICT (workspace_id) DO NOTHING", + ) + .bind(workspace_id) + .bind(now) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + self.find_workspace_billing(workspace_id) + .await? + .ok_or(AppError::NotFound("workspace billing not found".into())) + } + + async fn find_workspace_billing( + &self, + workspace_id: Uuid, + ) -> Result, AppError> { + sqlx::query_as::<_, WorkspaceBilling>( + "SELECT workspace_id, customer_id, subscription_id, plan, billing_email, status, \ + seats, trial_ends_at, current_period_start, current_period_end, canceled_at, \ + created_at, updated_at FROM workspace_billing WHERE workspace_id = $1", + ) + .bind(workspace_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } +} diff --git a/service/workspace/branding.rs b/service/workspace/branding.rs new file mode 100644 index 0000000..4622447 --- /dev/null +++ b/service/workspace/branding.rs @@ -0,0 +1,123 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::workspaces::WorkspaceCustomBranding; +use crate::service::WorkspaceService; +use crate::session::Session; + +use super::util::merge_optional_text; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateBrandingParams { + pub logo_url: Option, + pub favicon_url: Option, + pub primary_color: Option, + pub accent_color: Option, + pub custom_css: Option, + pub support_url: Option, + pub enabled: Option, +} + +impl WorkspaceService { + pub async fn workspace_branding( + &self, + ctx: &Session, + workspace_id: Uuid, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + self.ensure_workspace_branding(workspace_id).await + } + + pub async fn workspace_update_branding( + &self, + ctx: &Session, + workspace_id: Uuid, + params: UpdateBrandingParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + + let current = self.ensure_workspace_branding(workspace_id).await?; + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, WorkspaceCustomBranding>( + "UPDATE workspace_custom_branding SET logo_url = $1, favicon_url = $2, primary_color = $3, \ + accent_color = $4, custom_css = $5, support_url = $6, enabled = $7, updated_at = $8 \ + WHERE workspace_id = $9 \ + RETURNING workspace_id, logo_url, favicon_url, primary_color, accent_color, \ + custom_css, support_url, enabled, created_at, updated_at", + ) + .bind(merge_optional_text(params.logo_url, current.logo_url)) + .bind(merge_optional_text(params.favicon_url, current.favicon_url)) + .bind(merge_optional_text(params.primary_color, current.primary_color)) + .bind(merge_optional_text(params.accent_color, current.accent_color)) + .bind(merge_optional_text(params.custom_css, current.custom_css)) + .bind(merge_optional_text(params.support_url, current.support_url)) + .bind(params.enabled.unwrap_or(current.enabled)) + .bind(now) + .bind(workspace_id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + async fn ensure_workspace_branding( + &self, + workspace_id: Uuid, + ) -> Result { + if let Some(branding) = self.find_workspace_branding(workspace_id).await? { + return Ok(branding); + } + let now = chrono::Utc::now(); + sqlx::query( + "INSERT INTO workspace_custom_branding (workspace_id, enabled, created_at, updated_at) \ + VALUES ($1, false, $2, $2) ON CONFLICT (workspace_id) DO NOTHING", + ) + .bind(workspace_id) + .bind(now) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + self.find_workspace_branding(workspace_id) + .await? + .ok_or(AppError::NotFound("workspace branding not found".into())) + } + + async fn find_workspace_branding( + &self, + workspace_id: Uuid, + ) -> Result, AppError> { + sqlx::query_as::<_, WorkspaceCustomBranding>( + "SELECT workspace_id, logo_url, favicon_url, primary_color, accent_color, \ + custom_css, support_url, enabled, created_at, updated_at \ + FROM workspace_custom_branding WHERE workspace_id = $1", + ) + .bind(workspace_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } +} diff --git a/service/workspace/core.rs b/service/workspace/core.rs new file mode 100644 index 0000000..307c5af --- /dev/null +++ b/service/workspace/core.rs @@ -0,0 +1,625 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::{Role, Visibility}; +use crate::models::workspaces::Workspace; +use crate::service::WorkspaceService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum}; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateWorkspaceParams { + pub name: String, + pub description: Option, + pub visibility: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateWorkspaceParams { + pub name: Option, + pub description: Option, + pub visibility: Option, + pub default_role: Option, +} + +impl WorkspaceService { + pub async fn workspace_list( + &self, + ctx: &Session, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, Workspace>( + "SELECT id, owner_id, name, description, avatar_url, visibility, plan, status, \ + default_role, is_personal, archived_at, created_at, updated_at, deleted_at \ + FROM workspace WHERE deleted_at IS NULL AND (owner_id = $1 OR id IN \ + (SELECT workspace_id FROM workspace_member WHERE user_id = $1 AND status = 'active') \ + OR visibility = 'public') \ + ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(user_uid) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn workspace_get( + &self, + ctx: &Session, + workspace_id: Uuid, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_readable(user_uid, &ws).await?; + Ok(ws) + } + + pub async fn workspace_create( + &self, + ctx: &Session, + params: CreateWorkspaceParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + + let name = params.name.trim().to_string(); + if name.is_empty() { + return Err(AppError::BadRequest("name is required".into())); + } + + let visibility = match params.visibility { + Some(ref v) => parse_enum( + Some(v.clone()), + Visibility::Internal, + Visibility::Unknown, + "visibility", + )?, + None => Visibility::Internal, + }; + + let now = chrono::Utc::now(); + let workspace_id = Uuid::now_v7(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let ws = sqlx::query_as::<_, Workspace>( + "INSERT INTO workspace (id, owner_id, name, description, visibility, plan, status, \ + default_role, is_personal, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, 'free', 'active', 'member', false, $6, $6) \ + RETURNING id, owner_id, name, description, avatar_url, visibility, plan, status, \ + default_role, is_personal, archived_at, created_at, updated_at, deleted_at", + ) + .bind(workspace_id) + .bind(user_uid) + .bind(&name) + .bind(params.description.as_deref()) + .bind(visibility) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO workspace_member (id, workspace_id, user_id, role, status, joined_at, created_at, updated_at) \ + VALUES ($1, $2, $3, 'owner', 'active', $4, $4, $4)", + ) + .bind(Uuid::now_v7()) + .bind(workspace_id) + .bind(user_uid) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO workspace_settings (workspace_id, allow_public_repos, allow_member_invites, \ + require_two_factor, default_repo_visibility, default_branch_name, \ + issue_tracking_enabled, pull_requests_enabled, wiki_enabled, created_at, updated_at) \ + VALUES ($1, true, true, false, 'private', 'main', true, true, true, $2, $2)", + ) + .bind(workspace_id) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO workspace_stats (workspace_id, members_count, repos_count, issues_count, \ + pull_requests_count, storage_bytes, bandwidth_bytes, build_minutes_used, updated_at) \ + VALUES ($1, 1, 0, 0, 0, 0, 0, 0, $2)", + ) + .bind(workspace_id) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO workspace_billing (workspace_id, plan, status, seats, created_at, updated_at) \ + VALUES ($1, 'free', 'active', 1, $2, $2)", + ) + .bind(workspace_id) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO workspace_custom_branding (workspace_id, enabled, created_at, updated_at) \ + VALUES ($1, false, $2, $2)", + ) + .bind(workspace_id) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(ws) + } + + pub async fn workspace_update( + &self, + ctx: &Session, + workspace_id: Uuid, + params: UpdateWorkspaceParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + + let name = + merge_optional_text(params.name, Some(ws.name.clone())).unwrap_or(ws.name.clone()); + let description = merge_optional_text(params.description, ws.description); + let visibility = parse_enum( + params.visibility, + ws.visibility, + Visibility::Unknown, + "visibility", + )?; + let default_role = match params.default_role { + Some(ref v) => parse_enum( + Some(v.clone()), + ws.default_role.parse().unwrap_or(Role::Member), + Role::Unknown, + "default_role", + )?, + None => ws.default_role.parse().unwrap_or(Role::Member), + }; + + // Restrict default_role to safe roles only + match default_role { + Role::Member | Role::Contributor | Role::Viewer | Role::Guest => {} + _ => { + return Err(AppError::BadRequest( + "default_role must be one of: member, contributor, viewer, guest".into(), + )); + } + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, Workspace>( + "UPDATE workspace SET name = $1, description = $2, visibility = $3, default_role = $4, \ + updated_at = $5 WHERE id = $6 AND deleted_at IS NULL \ + RETURNING id, owner_id, name, description, avatar_url, visibility, plan, status, \ + default_role, is_personal, archived_at, created_at, updated_at, deleted_at", + ) + .bind(&name) + .bind(&description) + .bind(visibility) + .bind(default_role.to_string()) + .bind(now) + .bind(workspace_id) + .fetch_optional(&mut *txn) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("workspace not found".into()))?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn workspace_archive( + &self, + ctx: &Session, + workspace_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner) + .await?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE workspace SET status = 'archived', archived_at = $1, updated_at = $1 \ + WHERE id = $2 AND deleted_at IS NULL AND status <> 'archived'", + ) + .bind(now) + .bind(workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected( + result.rows_affected(), + "workspace not found or already archived", + )?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn workspace_unarchive( + &self, + ctx: &Session, + workspace_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner) + .await?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE workspace SET status = 'active', archived_at = NULL, updated_at = $1 \ + WHERE id = $2 AND deleted_at IS NULL AND status = 'archived'", + ) + .bind(now) + .bind(workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected( + result.rows_affected(), + "workspace not found or not archived", + )?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn workspace_delete( + &self, + ctx: &Session, + workspace_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner) + .await?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE workspace SET deleted_at = $1, status = 'deleted', updated_at = $1 \ + WHERE id = $2 AND deleted_at IS NULL", + ) + .bind(now) + .bind(workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "workspace not found")?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn workspace_transfer_owner( + &self, + ctx: &Session, + workspace_id: Uuid, + new_owner_id: Uuid, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner) + .await?; + + if new_owner_id == ws.owner_id { + return Err(AppError::BadRequest( + "new owner must be different from current owner".into(), + )); + } + let is_member = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')", + ) + .bind(workspace_id) + .bind(new_owner_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if !is_member { + return Err(AppError::BadRequest( + "new owner must be an active member".into(), + )); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE workspace_member SET role = 'owner', updated_at = $1 \ + WHERE workspace_id = $2 AND user_id = $3", + ) + .bind(now) + .bind(workspace_id) + .bind(new_owner_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE workspace_member SET role = 'admin', updated_at = $1 \ + WHERE workspace_id = $2 AND user_id = $3", + ) + .bind(now) + .bind(workspace_id) + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, Workspace>( + "UPDATE workspace SET owner_id = $1, updated_at = $2 WHERE id = $3 AND deleted_at IS NULL \ + RETURNING id, owner_id, name, description, avatar_url, visibility, plan, status, \ + default_role, is_personal, archived_at, created_at, updated_at, deleted_at", + ) + .bind(new_owner_id) + .bind(now) + .bind(workspace_id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn workspace_upload_avatar( + &self, + ctx: &Session, + workspace_id: Uuid, + data: Vec, + content_type: Option, + file_name: Option, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + + let ext = + crate::service::util::avatar_extension(content_type.as_deref(), file_name.as_deref())?; + crate::service::util::validate_avatar_size(data.len(), 5 * 1024 * 1024)?; + + let old_avatar_url = ws.avatar_url.clone(); + let storage_key = format!( + "workspaces/{}/avatar/{}.{}", + workspace_id, + Uuid::now_v7(), + ext + ); + self.ctx.storage.put(&storage_key, data).await?; + let avatar_url = self.ctx.storage.public_url(&storage_key).ok_or_else(|| { + AppError::Config("APP_S3_PUBLIC_URL is required for avatar upload".into()) + })?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, Workspace>( + "UPDATE workspace SET avatar_url = $1, updated_at = $2 \ + WHERE id = $3 AND deleted_at IS NULL \ + RETURNING id, owner_id, name, description, avatar_url, visibility, plan, status, \ + default_role, is_personal, archived_at, created_at, updated_at, deleted_at", + ) + .bind(&avatar_url) + .bind(now) + .bind(workspace_id) + .fetch_optional(&mut *txn) + .await + .map_err(AppError::Database)?; + + if let Some(updated) = result { + txn.commit().await.map_err(|_| AppError::TxnError)?; + if let Some(old_url) = old_avatar_url + && let Some(old_key) = extract_storage_key_from_url(&old_url) + { + let _ = self.ctx.storage.delete(&old_key).await; + } + Ok(updated) + } else { + let _ = self.ctx.storage.delete(&storage_key).await; + Err(AppError::NotFound("workspace not found".into())) + } + } + + pub(crate) async fn find_workspace_by_id( + &self, + workspace_id: Uuid, + ) -> Result { + sqlx::query_as::<_, Workspace>( + "SELECT id, owner_id, name, description, avatar_url, visibility, plan, status, \ + default_role, is_personal, archived_at, created_at, updated_at, deleted_at \ + FROM workspace WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(workspace_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("workspace not found".into())) + } + + pub async fn workspace_user_role( + &self, + user_uid: Uuid, + workspace_id: Uuid, + ) -> Result, AppError> { + let role_str: Option = sqlx::query_scalar( + "SELECT role FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active'", + ) + .bind(workspace_id) + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + match role_str { + Some(r) => Ok(Some(r.parse().unwrap_or(Role::Unknown))), + None => { + let ws = self.find_workspace_by_id(workspace_id).await?; + if ws.owner_id == user_uid { + return Ok(Some(Role::Owner)); + } + Ok(None) + } + } + } + + pub async fn ensure_workspace_readable( + &self, + user_uid: Uuid, + ws: &Workspace, + ) -> Result<(), AppError> { + if ws.owner_id == user_uid { + return Ok(()); + } + let is_member = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')", + ) + .bind(ws.id) + .bind(user_uid) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if is_member { + return Ok(()); + } + match ws.visibility { + Visibility::Public | Visibility::Internal => Ok(()), + _ => Err(AppError::Unauthorized), + } + } + + pub async fn ensure_workspace_role_at_least( + &self, + user_uid: Uuid, + ws: &Workspace, + min_role: Role, + ) -> Result { + if ws.owner_id == user_uid { + return Ok(Role::Owner); + } + let role_str: Option = sqlx::query_scalar( + "SELECT role FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active'", + ) + .bind(ws.id) + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let role = role_str + .and_then(|r| r.parse::().ok()) + .unwrap_or(Role::Unknown); + + if super::util::role_level(role) < super::util::role_level(min_role) { + return Err(AppError::Unauthorized); + } + Ok(role) + } +} + +use crate::service::util::extract_storage_key_from_url; diff --git a/service/workspace/domains.rs b/service/workspace/domains.rs new file mode 100644 index 0000000..52914fd --- /dev/null +++ b/service/workspace/domains.rs @@ -0,0 +1,263 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::workspaces::WorkspaceDomain; +use crate::service::WorkspaceService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, required_text}; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct AddDomainParams { + pub domain: String, +} + +impl WorkspaceService { + pub async fn workspace_domains( + &self, + ctx: &Session, + workspace_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, WorkspaceDomain>( + "SELECT id, workspace_id, domain, verification_token_hash, is_primary, is_verified, \ + verified_at, created_at, updated_at FROM workspace_domain \ + WHERE workspace_id = $1 ORDER BY is_primary DESC, created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(workspace_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn workspace_add_domain( + &self, + ctx: &Session, + workspace_id: Uuid, + params: AddDomainParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + let domain = required_text(params.domain, "domain")?.to_lowercase(); + + let is_first = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM workspace_domain WHERE workspace_id = $1", + ) + .bind(workspace_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + == 0; + + let token = Self::generate_domain_verification_token(); + let token_hash = sha256_hex(token.as_bytes()); + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, WorkspaceDomain>( + "INSERT INTO workspace_domain (id, workspace_id, domain, verification_token_hash, is_primary, is_verified, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, false, $6, $6) \ + RETURNING id, workspace_id, domain, verification_token_hash, is_primary, is_verified, verified_at, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(workspace_id) + .bind(&domain) + .bind(&token_hash) + .bind(is_first) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn workspace_verify_domain( + &self, + ctx: &Session, + workspace_id: Uuid, + domain_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE workspace_domain SET is_verified = true, verified_at = $1, updated_at = $1 \ + WHERE id = $2 AND workspace_id = $3 AND is_verified = false", + ) + .bind(now) + .bind(domain_id) + .bind(workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected( + result.rows_affected(), + "domain not found or already verified", + )?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn workspace_set_primary_domain( + &self, + ctx: &Session, + workspace_id: Uuid, + domain_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner) + .await?; + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let domain = sqlx::query_as::<_, WorkspaceDomain>( + "SELECT id, workspace_id, domain, verification_token_hash, is_primary, is_verified, \ + verified_at, created_at, updated_at FROM workspace_domain \ + WHERE id = $1 AND workspace_id = $2", + ) + .bind(domain_id) + .bind(workspace_id) + .fetch_optional(&mut *txn) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("domain not found".into()))?; + + if !domain.is_verified { + return Err(AppError::BadRequest( + "domain must be verified before setting as primary".into(), + )); + } + + sqlx::query("UPDATE workspace_domain SET is_primary = false, updated_at = $1 WHERE workspace_id = $2 AND is_primary = true") + .bind(now) + .bind(workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query("UPDATE workspace_domain SET is_primary = true, updated_at = $1 WHERE id = $2") + .bind(now) + .bind(domain_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn workspace_delete_domain( + &self, + ctx: &Session, + workspace_id: Uuid, + domain_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + + let is_primary = sqlx::query_scalar::<_, bool>( + "SELECT is_primary FROM workspace_domain WHERE id = $1 AND workspace_id = $2", + ) + .bind(domain_id) + .bind(workspace_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("domain not found".into()))?; + + if is_primary { + return Err(AppError::BadRequest("cannot delete primary domain".into())); + } + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = + sqlx::query("DELETE FROM workspace_domain WHERE id = $1 AND workspace_id = $2") + .bind(domain_id) + .bind(workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "domain not found")?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + fn generate_domain_verification_token() -> String { + (0..32) + .map(|_| format!("{:02x}", rand::random::())) + .collect() + } +} + +use crate::service::util::sha256_hex; diff --git a/service/workspace/integrations.rs b/service/workspace/integrations.rs new file mode 100644 index 0000000..f465d71 --- /dev/null +++ b/service/workspace/integrations.rs @@ -0,0 +1,220 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Provider; +use crate::models::workspaces::WorkspaceIntegration; +use crate::service::WorkspaceService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, required_text}; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateIntegrationParams { + pub provider: String, + pub name: String, + pub config: Option, + pub secret_ciphertext: Option, + pub enabled: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateIntegrationParams { + pub name: Option, + pub config: Option, + pub secret_ciphertext: Option, + pub enabled: Option, +} + +impl WorkspaceService { + pub async fn workspace_integrations( + &self, + ctx: &Session, + workspace_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, crate::models::common::Role::Admin) + .await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, WorkspaceIntegration>( + "SELECT id, workspace_id, provider, name, config, secret_ciphertext, enabled, \ + installed_by, last_used_at, created_at, updated_at FROM workspace_integration \ + WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(workspace_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn workspace_create_integration( + &self, + ctx: &Session, + workspace_id: Uuid, + params: CreateIntegrationParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, crate::models::common::Role::Admin) + .await?; + + let provider = params + .provider + .trim() + .parse::() + .map_err(|_| AppError::BadRequest("invalid provider".into()))?; + if provider == Provider::Unknown { + return Err(AppError::BadRequest("invalid provider".into())); + } + let name = required_text(params.name, "name")?; + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, WorkspaceIntegration>( + "INSERT INTO workspace_integration (id, workspace_id, provider, name, config, secret_ciphertext, \ + enabled, installed_by, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) \ + RETURNING id, workspace_id, provider, name, config, secret_ciphertext, enabled, \ + installed_by, last_used_at, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(workspace_id) + .bind(provider) + .bind(&name) + .bind(params.config.map(sqlx::types::Json)) + .bind(¶ms.secret_ciphertext) + .bind(params.enabled.unwrap_or(true)) + .bind(user_uid) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn workspace_update_integration( + &self, + ctx: &Session, + workspace_id: Uuid, + integration_id: Uuid, + params: UpdateIntegrationParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, crate::models::common::Role::Admin) + .await?; + + let current = sqlx::query_as::<_, WorkspaceIntegration>( + "SELECT id, workspace_id, provider, name, config, secret_ciphertext, enabled, \ + installed_by, last_used_at, created_at, updated_at FROM workspace_integration \ + WHERE id = $1 AND workspace_id = $2", + ) + .bind(integration_id) + .bind(workspace_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("integration not found".into()))?; + + let name = params + .name + .map(|n| n.trim().to_string()) + .unwrap_or(current.name); + let enabled = params.enabled.unwrap_or(current.enabled); + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, WorkspaceIntegration>( + "UPDATE workspace_integration SET name = $1, config = $2, secret_ciphertext = $3, \ + enabled = $4, updated_at = $5 WHERE id = $6 AND workspace_id = $7 \ + RETURNING id, workspace_id, provider, name, config, secret_ciphertext, enabled, \ + installed_by, last_used_at, created_at, updated_at", + ) + .bind(&name) + .bind( + params + .config + .map(sqlx::types::Json) + .or_else(|| current.config.clone()), + ) + .bind(params.secret_ciphertext.or(current.secret_ciphertext)) + .bind(enabled) + .bind(now) + .bind(integration_id) + .bind(workspace_id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn workspace_delete_integration( + &self, + ctx: &Session, + workspace_id: Uuid, + integration_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, crate::models::common::Role::Admin) + .await?; + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = + sqlx::query("DELETE FROM workspace_integration WHERE id = $1 AND workspace_id = $2") + .bind(integration_id) + .bind(workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "integration not found")?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } +} diff --git a/service/workspace/invitations.rs b/service/workspace/invitations.rs new file mode 100644 index 0000000..b1b9797 --- /dev/null +++ b/service/workspace/invitations.rs @@ -0,0 +1,323 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::workspaces::WorkspaceInvitation; +use crate::pb::email::{EmailAddress, SendEmailRequest}; +use crate::service::WorkspaceService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, role_level}; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateInvitationParams { + pub email: String, + pub role: Option, +} + +#[derive(Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateInvitationResponse { + pub invitation: WorkspaceInvitation, +} + +impl WorkspaceService { + pub async fn workspace_invitations( + &self, + ctx: &Session, + workspace_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, WorkspaceInvitation>( + "SELECT id, workspace_id, email, role, token_hash, invited_by, accepted_by, \ + accepted_at, revoked_at, expires_at, created_at FROM workspace_invitation \ + WHERE workspace_id = $1 AND revoked_at IS NULL AND accepted_at IS NULL \ + AND expires_at > NOW() ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(workspace_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn workspace_create_invitation( + &self, + ctx: &Session, + workspace_id: Uuid, + params: CreateInvitationParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + let actor_role = self + .ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + + let email = params.email.trim().to_lowercase(); + if email.is_empty() { + return Err(AppError::BadRequest("email is required".into())); + } + + let existing = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM workspace_invitation \ + WHERE workspace_id = $1 AND lower(email) = lower($2) \ + AND revoked_at IS NULL AND accepted_at IS NULL AND expires_at > NOW())", + ) + .bind(workspace_id) + .bind(&email) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if existing { + return Err(AppError::BadRequest( + "invitation already exists for this email".into(), + )); + } + + let role = params + .role + .as_deref() + .and_then(|r| r.parse::().ok()) + .unwrap_or(ws.default_role.parse().unwrap_or(Role::Member)); + + if role == Role::Owner || role == Role::Unknown { + return Err(AppError::BadRequest("invalid role for invitation".into())); + } + + // Non-owner admins cannot invite with roles equal to or higher than their own + if actor_role != Role::Owner && role_level(role) >= role_level(actor_role) { + return Err(AppError::BadRequest( + "cannot invite with role equal to or higher than your own".into(), + )); + } + + let token = Self::generate_invitation_token(); + let token_hash = sha256_hex(token.as_bytes()); + let now = chrono::Utc::now(); + let expires_at = now + chrono::Duration::days(7); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let invitation = sqlx::query_as::<_, WorkspaceInvitation>( + "INSERT INTO workspace_invitation (id, workspace_id, email, role, token_hash, invited_by, expires_at, created_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \ + RETURNING id, workspace_id, email, role, token_hash, invited_by, accepted_by, \ + accepted_at, revoked_at, expires_at, created_at", + ) + .bind(Uuid::now_v7()) + .bind(workspace_id) + .bind(&email) + .bind(role.to_string()) + .bind(&token_hash) + .bind(user_uid) + .bind(expires_at) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + + let domain = self.ctx.config.main_domain()?; + let invite_link = format!("{}/workspace/invitations/accept?token={}", domain, token); + let mut mail = self + .ctx + .registry + .get_email_client() + .ok_or(AppError::Config("mail service not available".into()))?; + mail.send_email(tonic::Request::new(SendEmailRequest { + to: vec![EmailAddress { + email: email.clone(), + name: String::new(), + }], + subject: format!("You're invited to join {}", ws.name), + text_body: format!( + "You've been invited to join workspace '{}'.\n\nAccept the invitation here:\n\n{}\n\nThis invitation expires in 7 days.", + ws.name, invite_link + ), + ..Default::default() + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + tracing::info!(email = %email, invitation_id = %invitation.id, "Invitation created"); + + Ok(CreateInvitationResponse { invitation }) + } + + pub async fn workspace_revoke_invitation( + &self, + ctx: &Session, + workspace_id: Uuid, + invitation_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE workspace_invitation SET revoked_at = $1 WHERE id = $2 AND workspace_id = $3 \ + AND revoked_at IS NULL AND accepted_at IS NULL", + ) + .bind(now) + .bind(invitation_id) + .bind(workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected( + result.rows_affected(), + "invitation not found or already used", + )?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn workspace_accept_invitation( + &self, + ctx: &Session, + token: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let token_hash = sha256_hex(token.as_bytes()); + let now = chrono::Utc::now(); + + let invitation = sqlx::query_as::<_, WorkspaceInvitation>( + "SELECT id, workspace_id, email, role, token_hash, invited_by, accepted_by, \ + accepted_at, revoked_at, expires_at, created_at FROM workspace_invitation \ + WHERE token_hash = $1 AND revoked_at IS NULL AND accepted_at IS NULL AND expires_at > NOW()", + ) + .bind(&token_hash) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::BadRequest("invalid or expired invitation".into()))?; + + let already_member = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2)", + ) + .bind(invitation.workspace_id) + .bind(user_uid) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if already_member { + return Err(AppError::BadRequest( + "already a member of this workspace".into(), + )); + } + + let user_email: Option = sqlx::query_scalar( + "SELECT email FROM user_mail WHERE user_id = $1 AND is_verified = true LIMIT 1", + ) + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + if user_email + .as_deref() + .map(|e| e.trim().eq_ignore_ascii_case(&invitation.email)) + != Some(true) + { + return Err(AppError::Unauthorized); + } + + let role_str = invitation.role.to_string(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, WorkspaceInvitation>( + "UPDATE workspace_invitation SET accepted_by = $1, accepted_at = $2 \ + WHERE id = $3 AND revoked_at IS NULL AND accepted_at IS NULL \ + RETURNING id, workspace_id, email, role, token_hash, invited_by, accepted_by, \ + accepted_at, revoked_at, expires_at, created_at", + ) + .bind(user_uid) + .bind(now) + .bind(invitation.id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "INSERT INTO workspace_member (id, workspace_id, user_id, role, status, invited_by, joined_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, 'active', $5, $6, $6, $6) \ + ON CONFLICT (workspace_id, user_id) DO NOTHING", + ) + .bind(Uuid::now_v7()) + .bind(invitation.workspace_id) + .bind(user_uid) + .bind(&role_str) + .bind(invitation.invited_by) + .bind(now) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE workspace_stats SET members_count = members_count + 1, updated_at = $1 WHERE workspace_id = $2", + ) + .bind(now) + .bind(invitation.workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + fn generate_invitation_token() -> String { + (0..64) + .map(|_| format!("{:02x}", rand::random::())) + .collect() + } +} + +use crate::service::util::sha256_hex; diff --git a/service/workspace/members.rs b/service/workspace/members.rs new file mode 100644 index 0000000..a6dd935 --- /dev/null +++ b/service/workspace/members.rs @@ -0,0 +1,350 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::util::{clamp_limit_offset, ensure_affected, role_level}; +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::workspaces::WorkspaceMember; +use crate::service::WorkspaceService; +use crate::session::Session; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema, utoipa::IntoParams)] +pub struct AddMemberParams { + pub user_id: Uuid, + pub role: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateMemberRoleParams { + pub role: String, +} + +impl WorkspaceService { + pub async fn workspace_members( + &self, + ctx: &Session, + workspace_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_readable(user_uid, &ws).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, WorkspaceMember>( + "SELECT id, workspace_id, user_id, role, status, invited_by, joined_at, \ + last_active_at, created_at, updated_at FROM workspace_member \ + WHERE workspace_id = $1 AND status = 'active' ORDER BY created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(workspace_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn workspace_add_member( + &self, + ctx: &Session, + workspace_id: Uuid, + params: AddMemberParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + let actor_role = self + .ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + + let settings_allow = sqlx::query_scalar::<_, bool>( + "SELECT allow_member_invites FROM workspace_settings WHERE workspace_id = $1", + ) + .bind(workspace_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if !settings_allow && role_level(actor_role) < role_level(Role::Owner) { + return Err(AppError::BadRequest( + "member invitations are disabled".into(), + )); + } + + let existing = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2)", + ) + .bind(workspace_id) + .bind(params.user_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if existing { + return Err(AppError::Conflict("user is already a member".into())); + } + + let role = params + .role + .as_deref() + .and_then(|r| r.parse::().ok()) + .unwrap_or(ws.default_role.parse().unwrap_or(Role::Member)); + + if role == Role::Owner { + return Err(AppError::BadRequest("cannot add member as owner".into())); + } + if role == Role::Unknown { + return Err(AppError::BadRequest("invalid role".into())); + } + + // Non-owner admins cannot grant roles equal to or higher than their own + if actor_role != Role::Owner && role_level(role) >= role_level(actor_role) { + return Err(AppError::BadRequest( + "cannot grant role equal to or higher than your own".into(), + )); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let member = sqlx::query_as::<_, WorkspaceMember>( + "INSERT INTO workspace_member (id, workspace_id, user_id, role, status, invited_by, joined_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, 'active', $5, $6, $6, $6) \ + RETURNING id, workspace_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(workspace_id) + .bind(params.user_id) + .bind(role.to_string()) + .bind(user_uid) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE workspace_stats SET members_count = members_count + 1, updated_at = $1 WHERE workspace_id = $2", + ) + .bind(now) + .bind(workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(member) + } + + pub async fn workspace_update_member_role( + &self, + ctx: &Session, + workspace_id: Uuid, + member_id: Uuid, + params: UpdateMemberRoleParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + let actor_role = self + .ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + + let new_role = params + .role + .parse::() + .map_err(|_| AppError::BadRequest("invalid role".into()))?; + if new_role == Role::Owner { + return Err(AppError::BadRequest( + "use workspace_transfer_owner to change owner".into(), + )); + } + if new_role == Role::Unknown { + return Err(AppError::BadRequest("invalid role".into())); + } + + // Non-owner admins cannot grant roles equal to or higher than their own + if actor_role != Role::Owner && role_level(new_role) >= role_level(actor_role) { + return Err(AppError::BadRequest( + "cannot grant role equal to or higher than your own".into(), + )); + } + + let target = sqlx::query_as::<_, WorkspaceMember>( + "SELECT id, workspace_id, user_id, role, status, invited_by, joined_at, \ + last_active_at, created_at, updated_at FROM workspace_member \ + WHERE id = $1 AND workspace_id = $2", + ) + .bind(member_id) + .bind(workspace_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("member not found".into()))?; + + if target.role == Role::Owner { + return Err(AppError::BadRequest( + "cannot change owner role; use workspace_transfer_owner".into(), + )); + } + if role_level(actor_role) <= role_level(target.role) && actor_role != Role::Owner { + return Err(AppError::BadRequest( + "cannot change role of a member with equal or higher role".into(), + )); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, WorkspaceMember>( + "UPDATE workspace_member SET role = $1, updated_at = $2 WHERE id = $3 AND workspace_id = $4 \ + RETURNING id, workspace_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at", + ) + .bind(new_role.to_string()) + .bind(now) + .bind(member_id) + .bind(workspace_id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn workspace_remove_member( + &self, + ctx: &Session, + workspace_id: Uuid, + member_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + let actor_role = self + .ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + + let target = sqlx::query_as::<_, WorkspaceMember>( + "SELECT id, workspace_id, user_id, role, status, invited_by, joined_at, \ + last_active_at, created_at, updated_at FROM workspace_member \ + WHERE id = $1 AND workspace_id = $2", + ) + .bind(member_id) + .bind(workspace_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("member not found".into()))?; + + if target.role == Role::Owner { + return Err(AppError::BadRequest( + "cannot remove owner; transfer ownership first".into(), + )); + } + if role_level(actor_role) <= role_level(target.role) && actor_role != Role::Owner { + return Err(AppError::BadRequest( + "cannot remove a member with equal or higher role".into(), + )); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = + sqlx::query("DELETE FROM workspace_member WHERE id = $1 AND workspace_id = $2") + .bind(member_id) + .bind(workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "member not found")?; + + sqlx::query( + "UPDATE workspace_stats SET members_count = GREATEST(members_count - 1, 0), updated_at = $1 WHERE workspace_id = $2", + ) + .bind(now) + .bind(workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + + pub async fn workspace_leave(&self, ctx: &Session, workspace_id: Uuid) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + + if ws.owner_id == user_uid { + return Err(AppError::BadRequest( + "owner cannot leave; transfer ownership first".into(), + )); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = + sqlx::query("DELETE FROM workspace_member WHERE workspace_id = $1 AND user_id = $2") + .bind(workspace_id) + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "not a member")?; + + sqlx::query( + "UPDATE workspace_stats SET members_count = GREATEST(members_count - 1, 0), updated_at = $1 WHERE workspace_id = $2", + ) + .bind(now) + .bind(workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } +} diff --git a/service/workspace/mod.rs b/service/workspace/mod.rs new file mode 100644 index 0000000..3d6941e --- /dev/null +++ b/service/workspace/mod.rs @@ -0,0 +1,13 @@ +pub mod approvals; +pub mod audit; +pub mod billing; +pub mod branding; +pub mod core; +pub mod domains; +pub mod integrations; +pub mod invitations; +pub mod members; +pub mod settings; +pub mod stats; +pub mod util; +pub mod webhooks; diff --git a/service/workspace/settings.rs b/service/workspace/settings.rs new file mode 100644 index 0000000..c60251f --- /dev/null +++ b/service/workspace/settings.rs @@ -0,0 +1,128 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::workspaces::WorkspaceSettings; +use crate::service::WorkspaceService; +use crate::session::Session; + +use super::util::merge_optional_text; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateWorkspaceSettingsParams { + pub allow_public_repos: Option, + pub allow_member_invites: Option, + pub require_two_factor: Option, + pub default_repo_visibility: Option, + pub default_branch_name: Option, + pub issue_tracking_enabled: Option, + pub pull_requests_enabled: Option, + pub wiki_enabled: Option, +} + +impl WorkspaceService { + pub async fn workspace_settings( + &self, + ctx: &Session, + workspace_id: Uuid, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_readable(user_uid, &ws).await?; + self.ensure_user_workspace_settings(workspace_id).await + } + + pub async fn workspace_update_settings( + &self, + ctx: &Session, + workspace_id: Uuid, + params: UpdateWorkspaceSettingsParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, crate::models::common::Role::Admin) + .await?; + + let current = self.ensure_user_workspace_settings(workspace_id).await?; + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, WorkspaceSettings>( + "UPDATE workspace_settings SET \ + allow_public_repos = $1, allow_member_invites = $2, require_two_factor = $3, \ + default_repo_visibility = $4, default_branch_name = $5, \ + issue_tracking_enabled = $6, pull_requests_enabled = $7, wiki_enabled = $8, updated_at = $9 \ + WHERE workspace_id = $10 \ + RETURNING workspace_id, allow_public_repos, allow_member_invites, require_two_factor, \ + default_repo_visibility, default_branch_name, issue_tracking_enabled, \ + pull_requests_enabled, wiki_enabled, created_at, updated_at", + ) + .bind(params.allow_public_repos.unwrap_or(current.allow_public_repos)) + .bind(params.allow_member_invites.unwrap_or(current.allow_member_invites)) + .bind(params.require_two_factor.unwrap_or(current.require_two_factor)) + .bind(merge_optional_text(params.default_repo_visibility, Some(current.default_repo_visibility.clone())).unwrap_or_else(|| current.default_repo_visibility.clone())) + .bind(merge_optional_text(params.default_branch_name, Some(current.default_branch_name.clone())).unwrap_or_else(|| current.default_branch_name.clone())) + .bind(params.issue_tracking_enabled.unwrap_or(current.issue_tracking_enabled)) + .bind(params.pull_requests_enabled.unwrap_or(current.pull_requests_enabled)) + .bind(params.wiki_enabled.unwrap_or(current.wiki_enabled)) + .bind(now) + .bind(workspace_id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + async fn ensure_user_workspace_settings( + &self, + workspace_id: Uuid, + ) -> Result { + if let Some(settings) = self.find_workspace_settings(workspace_id).await? { + return Ok(settings); + } + let now = chrono::Utc::now(); + sqlx::query( + "INSERT INTO workspace_settings (workspace_id, allow_public_repos, allow_member_invites, \ + require_two_factor, default_repo_visibility, default_branch_name, \ + issue_tracking_enabled, pull_requests_enabled, wiki_enabled, created_at, updated_at) \ + VALUES ($1, true, true, false, 'private', 'main', true, true, true, $2, $2) ON CONFLICT (workspace_id) DO NOTHING", + ) + .bind(workspace_id) + .bind(now) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + self.find_workspace_settings(workspace_id) + .await? + .ok_or(AppError::NotFound("workspace settings not found".into())) + } + + async fn find_workspace_settings( + &self, + workspace_id: Uuid, + ) -> Result, AppError> { + sqlx::query_as::<_, WorkspaceSettings>( + "SELECT workspace_id, allow_public_repos, allow_member_invites, require_two_factor, \ + default_repo_visibility, default_branch_name, issue_tracking_enabled, \ + pull_requests_enabled, wiki_enabled, created_at, updated_at \ + FROM workspace_settings WHERE workspace_id = $1", + ) + .bind(workspace_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } +} diff --git a/service/workspace/stats.rs b/service/workspace/stats.rs new file mode 100644 index 0000000..92e476c --- /dev/null +++ b/service/workspace/stats.rs @@ -0,0 +1,117 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::workspaces::WorkspaceStats; +use crate::service::WorkspaceService; +use crate::session::Session; + +impl WorkspaceService { + pub async fn workspace_stats( + &self, + ctx: &Session, + workspace_id: Uuid, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_readable(user_uid, &ws).await?; + self.ensure_workspace_stats(workspace_id).await + } + + pub async fn workspace_refresh_stats( + &self, + ctx: &Session, + workspace_id: Uuid, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + + let members_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM workspace_member WHERE workspace_id = $1 AND status = 'active'", + ) + .bind(workspace_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let repos_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM repo WHERE workspace_id = $1 AND deleted_at IS NULL", + ) + .bind(workspace_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let issues_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM issue WHERE repo_id IN (SELECT id FROM repo WHERE workspace_id = $1 AND deleted_at IS NULL) AND deleted_at IS NULL", + ) + .bind(workspace_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let prs_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM pull_request WHERE repo_id IN (SELECT id FROM repo WHERE workspace_id = $1 AND deleted_at IS NULL) AND deleted_at IS NULL", + ) + .bind(workspace_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let now = chrono::Utc::now(); + let result = sqlx::query_as::<_, WorkspaceStats>( + "UPDATE workspace_stats SET members_count = $1, repos_count = $2, issues_count = $3, \ + pull_requests_count = $4, updated_at = $5 WHERE workspace_id = $6 \ + RETURNING workspace_id, members_count, repos_count, issues_count, pull_requests_count, \ + storage_bytes, bandwidth_bytes, build_minutes_used, last_activity_at, updated_at", + ) + .bind(members_count) + .bind(repos_count) + .bind(issues_count) + .bind(prs_count) + .bind(now) + .bind(workspace_id) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + Ok(result) + } + + async fn ensure_workspace_stats(&self, workspace_id: Uuid) -> Result { + if let Some(stats) = sqlx::query_as::<_, WorkspaceStats>( + "SELECT workspace_id, members_count, repos_count, issues_count, pull_requests_count, \ + storage_bytes, bandwidth_bytes, build_minutes_used, last_activity_at, updated_at \ + FROM workspace_stats WHERE workspace_id = $1", + ) + .bind(workspace_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + { + return Ok(stats); + } + + sqlx::query( + "INSERT INTO workspace_stats (workspace_id, members_count, repos_count, issues_count, \ + pull_requests_count, storage_bytes, bandwidth_bytes, build_minutes_used, updated_at) \ + VALUES ($1, 0, 0, 0, 0, 0, 0, 0, $2) ON CONFLICT (workspace_id) DO NOTHING", + ) + .bind(workspace_id) + .bind(chrono::Utc::now()) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + sqlx::query_as::<_, WorkspaceStats>( + "SELECT workspace_id, members_count, repos_count, issues_count, pull_requests_count, \ + storage_bytes, bandwidth_bytes, build_minutes_used, last_activity_at, updated_at \ + FROM workspace_stats WHERE workspace_id = $1", + ) + .bind(workspace_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } +} diff --git a/service/workspace/util.rs b/service/workspace/util.rs new file mode 100644 index 0000000..a83877c --- /dev/null +++ b/service/workspace/util.rs @@ -0,0 +1,3 @@ +pub use crate::service::util::{ + clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text, role_level, +}; diff --git a/service/workspace/webhooks.rs b/service/workspace/webhooks.rs new file mode 100644 index 0000000..fde84b2 --- /dev/null +++ b/service/workspace/webhooks.rs @@ -0,0 +1,267 @@ +use serde::{Deserialize, Serialize}; +use std::net::IpAddr; +use url::Url; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::{EventType, Role}; +use crate::models::workspaces::WorkspaceWebhook; +use crate::service::WorkspaceService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, ensure_affected, required_text}; + +/// Validate webhook URL for SSRF protection +fn validate_webhook_url(url_str: &str) -> Result<(), AppError> { + let url = Url::parse(url_str).map_err(|_| AppError::BadRequest("Invalid URL format".into()))?; + + // Only allow HTTPS + if url.scheme() != "https" { + return Err(AppError::BadRequest( + "Webhook URL must use HTTPS protocol".into(), + )); + } + + let host = url + .host_str() + .ok_or_else(|| AppError::BadRequest("URL must have a host".into()))?; + + // Reject IP addresses directly (require domain names) + if host.parse::().is_ok() { + return Err(AppError::BadRequest( + "Webhook URL must use a domain name, not an IP address".into(), + )); + } + + // Reject localhost and common local domains + let host_lower = host.to_lowercase(); + if host_lower == "localhost" + || host_lower.ends_with(".localhost") + || host_lower == "127.0.0.1" + || host_lower == "::1" + || host_lower == "0.0.0.0" + || host_lower.ends_with(".local") + || host_lower.ends_with(".internal") + { + return Err(AppError::BadRequest( + "Webhook URL cannot point to localhost or internal domains".into(), + )); + } + + // Reject metadata endpoints (AWS, GCP, Azure) + if host == "169.254.169.254" || host == "metadata.google.internal" { + return Err(AppError::BadRequest( + "Webhook URL cannot point to cloud metadata endpoints".into(), + )); + } + + Ok(()) +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreateWebhookParams { + pub url: String, + pub secret_ciphertext: Option, + pub events: Vec, + pub active: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateWebhookParams { + pub url: Option, + pub secret_ciphertext: Option, + pub events: Option>, + pub active: Option, +} + +impl WorkspaceService { + pub async fn workspace_webhooks( + &self, + ctx: &Session, + workspace_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, WorkspaceWebhook>( + "SELECT id, workspace_id, url, secret_ciphertext, events, active, \ + last_delivery_status, last_delivery_at, created_by, created_at, updated_at \ + FROM workspace_webhook WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(workspace_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn workspace_create_webhook( + &self, + ctx: &Session, + workspace_id: Uuid, + params: CreateWebhookParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + + let url = required_text(params.url, "url")?; + validate_webhook_url(&url)?; + if params.events.is_empty() { + return Err(AppError::BadRequest( + "at least one event is required".into(), + )); + } + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, WorkspaceWebhook>( + "INSERT INTO workspace_webhook (id, workspace_id, url, secret_ciphertext, events, active, \ + created_by, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \ + RETURNING id, workspace_id, url, secret_ciphertext, events, active, \ + last_delivery_status, last_delivery_at, created_by, created_at, updated_at", + ) + .bind(Uuid::now_v7()) + .bind(workspace_id) + .bind(&url) + .bind(¶ms.secret_ciphertext) + .bind(¶ms.events) + .bind(params.active.unwrap_or(true)) + .bind(user_uid) + .bind(now) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn workspace_update_webhook( + &self, + ctx: &Session, + workspace_id: Uuid, + webhook_id: Uuid, + params: UpdateWebhookParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + + let current = sqlx::query_as::<_, WorkspaceWebhook>( + "SELECT id, workspace_id, url, secret_ciphertext, events, active, \ + last_delivery_status, last_delivery_at, created_by, created_at, updated_at \ + FROM workspace_webhook WHERE id = $1 AND workspace_id = $2", + ) + .bind(webhook_id) + .bind(workspace_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("webhook not found".into()))?; + + let url = params + .url + .as_ref() + .map(|u| u.trim().to_string()) + .unwrap_or(current.url); + + // Validate URL if it was updated + if params.url.is_some() { + validate_webhook_url(&url)?; + } + + let active = params.active.unwrap_or(current.active); + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, WorkspaceWebhook>( + "UPDATE workspace_webhook SET url = $1, secret_ciphertext = $2, events = $3, \ + active = $4, updated_at = $5 WHERE id = $6 AND workspace_id = $7 \ + RETURNING id, workspace_id, url, secret_ciphertext, events, active, \ + last_delivery_status, last_delivery_at, created_by, created_at, updated_at", + ) + .bind(&url) + .bind(params.secret_ciphertext.or(current.secret_ciphertext)) + .bind(params.events.unwrap_or(current.events)) + .bind(active) + .bind(now) + .bind(webhook_id) + .bind(workspace_id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + + pub async fn workspace_delete_webhook( + &self, + ctx: &Session, + workspace_id: Uuid, + webhook_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.find_workspace_by_id(workspace_id).await?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query("SET LOCAL app.current_user_id = $1") + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = + sqlx::query("DELETE FROM workspace_webhook WHERE id = $1 AND workspace_id = $2") + .bind(webhook_id) + .bind(workspace_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "webhook not found")?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } +} diff --git a/session/config.rs b/session/config.rs new file mode 100644 index 0000000..81c76b2 --- /dev/null +++ b/session/config.rs @@ -0,0 +1,36 @@ +use crate::config::AppConfig; +use crate::error::AppResult; + +impl AppConfig { + pub fn session_cookie_name(&self) -> AppResult { + self.get_env_or("APP_SESSION_COOKIE_NAME", "sid".to_string()) + } + + pub fn session_cookie_secure(&self) -> AppResult { + self.get_env_or("APP_SESSION_COOKIE_SECURE", true) + } + + pub fn session_cookie_http_only(&self) -> AppResult { + self.get_env_or("APP_SESSION_COOKIE_HTTP_ONLY", true) + } + + pub fn session_cookie_same_site(&self) -> AppResult { + self.get_env_or("APP_SESSION_COOKIE_SAME_SITE", "Lax".to_string()) + } + + pub fn session_cookie_path(&self) -> AppResult { + self.get_env_or("APP_SESSION_COOKIE_PATH", "/".to_string()) + } + + pub fn session_cookie_domain(&self) -> AppResult> { + self.get_env::("APP_SESSION_COOKIE_DOMAIN") + } + + pub fn session_ttl_secs(&self) -> AppResult { + self.get_env_or("APP_SESSION_TTL_SECS", 86400) + } + + pub fn session_max_age_secs(&self) -> AppResult> { + self.get_env::("APP_SESSION_MAX_AGE_SECS") + } +} diff --git a/session/mod.rs b/session/mod.rs new file mode 100644 index 0000000..f85edc1 --- /dev/null +++ b/session/mod.rs @@ -0,0 +1,9 @@ +pub mod config; +#[allow(clippy::module_inception)] +pub mod session; +pub mod storage; + +pub use self::{ + session::{Session, SessionState, SessionStatus, SessionUser}, + storage::{RedisSessionStore, SessionKey, SessionStore, generate_session_key}, +}; diff --git a/session/session.rs b/session/session.rs new file mode 100644 index 0000000..0c2042a --- /dev/null +++ b/session/session.rs @@ -0,0 +1,195 @@ +use std::cell::{Ref, RefCell}; +use std::rc::Rc; + +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::{Map, Value}; +use uuid::Uuid; + +use crate::error::AppError; + +const SESSION_USER_KEY: &str = "session:user_uid"; + +#[derive(Clone)] +pub struct Session(Rc>); + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum SessionStatus { + Changed, + Purged, + Renewed, + #[default] + Unchanged, +} + +#[derive(Default)] +struct SessionInner { + state: Map, + status: SessionStatus, +} + +impl Session { + pub fn new() -> Self { + Self::default() + } + + pub fn from_state(state: Map) -> Self { + Self(Rc::new(RefCell::new(SessionInner { + state, + status: SessionStatus::Unchanged, + }))) + } + + pub fn get(&self, key: &str) -> Result, AppError> { + if let Some(value) = self.0.borrow().state.get(key) { + Ok(Some(serde_json::from_value(value.clone())?)) + } else { + Ok(None) + } + } + + pub fn contains_key(&self, key: &str) -> bool { + self.0.borrow().state.contains_key(key) + } + + pub fn entries(&self) -> Ref<'_, Map> { + Ref::map(self.0.borrow(), |inner| &inner.state) + } + + pub fn status(&self) -> SessionStatus { + Ref::map(self.0.borrow(), |inner| &inner.status).clone() + } + + pub fn insert(&self, key: impl Into, value: T) -> Result<(), AppError> { + let mut inner = self.0.borrow_mut(); + + if inner.status != SessionStatus::Purged { + if inner.status != SessionStatus::Renewed { + inner.status = SessionStatus::Changed; + } + let val = serde_json::to_value(&value)?; + inner.state.insert(key.into(), val); + } + + Ok(()) + } + + pub fn update( + &self, + key: impl Into, + updater: F, + ) -> Result<(), AppError> + where + F: FnOnce(T) -> T, + { + let mut inner = self.0.borrow_mut(); + let key_str = key.into(); + + if let Some(val) = inner.state.get(&key_str) { + if inner.status == SessionStatus::Purged { + return Ok(()); + } + + let value: T = serde_json::from_value(val.clone())?; + let updated = serde_json::to_value(updater(value))?; + + if inner.status != SessionStatus::Renewed { + inner.status = SessionStatus::Changed; + } + inner.state.insert(key_str, updated); + } + + Ok(()) + } + + pub fn update_or( + &self, + key: &str, + default: T, + updater: F, + ) -> Result<(), AppError> + where + F: FnOnce(T) -> T, + { + if self.contains_key(key) { + self.update(key, updater) + } else { + self.insert(key, default) + } + } + + pub fn remove(&self, key: &str) -> Option { + let mut inner = self.0.borrow_mut(); + + if inner.status != SessionStatus::Purged { + if inner.status != SessionStatus::Renewed { + inner.status = SessionStatus::Changed; + } + return inner.state.remove(key); + } + + None + } + + pub fn remove_as(&self, key: &str) -> Option> { + self.remove(key) + .map(|value| serde_json::from_value(value).map_err(AppError::Json)) + } + + pub fn clear(&self) { + let mut inner = self.0.borrow_mut(); + + if inner.status != SessionStatus::Purged { + if inner.status != SessionStatus::Renewed { + inner.status = SessionStatus::Changed; + } + inner.state.clear(); + } + } + + pub fn purge(&self) { + let mut inner = self.0.borrow_mut(); + inner.status = SessionStatus::Purged; + inner.state.clear(); + } + + pub fn renew(&self) { + let mut inner = self.0.borrow_mut(); + + if inner.status != SessionStatus::Purged { + inner.status = SessionStatus::Renewed; + } + } + + pub fn user(&self) -> Option { + self.get::(SESSION_USER_KEY).ok().flatten() + } + + pub fn set_user(&self, uid: Uuid) { + let _ = self.insert(SESSION_USER_KEY, uid); + } + + pub fn clear_user(&self) { + let _ = self.remove(SESSION_USER_KEY); + } + + pub fn take_state(&self) -> SessionState { + let mut inner = self.0.borrow_mut(); + std::mem::take(&mut inner.state) + } + + pub fn mark_unchanged(&self) { + self.0.borrow_mut().status = SessionStatus::Unchanged; + } +} + +impl Default for Session { + fn default() -> Self { + Self(Rc::new(RefCell::new(SessionInner::default()))) + } +} + +pub type SessionState = Map; + +#[derive(Debug, Clone, Copy)] +pub struct SessionUser(pub Uuid); diff --git a/session/storage/format.rs b/session/storage/format.rs new file mode 100644 index 0000000..014420a --- /dev/null +++ b/session/storage/format.rs @@ -0,0 +1,60 @@ +use serde::ser::{Serialize, SerializeMap, Serializer}; +use serde_json::Value; + +use crate::error::AppError; + +use super::interface::SessionState; + +const SESSION_STATE_FORMAT_VERSION: u8 = 1; + +struct StoredSessionStateRef<'a> { + state: &'a SessionState, +} + +impl Serialize for StoredSessionStateRef<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry("v", &SESSION_STATE_FORMAT_VERSION)?; + map.serialize_entry("state", self.state)?; + map.end() + } +} + +pub fn serialize_session_state(session_state: &SessionState) -> Result { + let stored = StoredSessionStateRef { + state: session_state, + }; + serde_json::to_string(&stored).map_err(AppError::Json) +} + +pub fn deserialize_session_state(value: &str) -> Result { + let value: Value = serde_json::from_str(value)?; + let Value::Object(mut obj) = value else { + return Err(AppError::Config("invalid session state format".into())); + }; + + if let Some(Value::Object(_)) = obj.get("state") + && let Some(Value::Number(v)) = obj.get("v") + { + let version = v + .as_u64() + .and_then(|n| u8::try_from(n).ok()) + .ok_or_else(|| AppError::Config("invalid session state format version".into()))?; + + if version != SESSION_STATE_FORMAT_VERSION { + return Err(AppError::Config(format!( + "unsupported session state format version: {version}" + ))); + } + + let Some(Value::Object(state)) = obj.remove("state") else { + return Err(AppError::Config("missing session state".into())); + }; + return Ok(state); + } + + Ok(obj) +} diff --git a/session/storage/interface.rs b/session/storage/interface.rs new file mode 100644 index 0000000..8145127 --- /dev/null +++ b/session/storage/interface.rs @@ -0,0 +1,36 @@ +use std::future::Future; + +use serde_json::{Map, Value}; + +use super::SessionKey; +use crate::error::AppError; + +pub type SessionState = Map; + +pub trait SessionStore { + fn load( + &self, + session_key: &SessionKey, + ) -> impl Future, AppError>>; + + fn save( + &self, + session_state: SessionState, + ttl_secs: u64, + ) -> impl Future>; + + fn update( + &self, + session_key: SessionKey, + session_state: SessionState, + ttl_secs: u64, + ) -> impl Future>; + + fn update_ttl( + &self, + session_key: &SessionKey, + ttl_secs: u64, + ) -> impl Future>; + + fn delete(&self, session_key: &SessionKey) -> impl Future>; +} diff --git a/session/storage/mod.rs b/session/storage/mod.rs new file mode 100644 index 0000000..1ca3c2a --- /dev/null +++ b/session/storage/mod.rs @@ -0,0 +1,12 @@ +mod format; +mod interface; +mod redis; +mod session_key; +mod utils; + +pub use self::{ + interface::{SessionState, SessionStore}, + redis::RedisSessionStore, + session_key::SessionKey, + utils::generate_session_key, +}; diff --git a/session/storage/redis.rs b/session/storage/redis.rs new file mode 100644 index 0000000..0b5fcfa --- /dev/null +++ b/session/storage/redis.rs @@ -0,0 +1,82 @@ +use crate::cache::redis::AppRedis; +use crate::error::AppError; + +use super::SessionKey; +use super::format::{deserialize_session_state, serialize_session_state}; +use super::interface::{SessionState, SessionStore}; +use super::utils::generate_session_key; + +pub struct RedisSessionStore { + redis: AppRedis, +} + +impl RedisSessionStore { + pub fn new(redis: AppRedis) -> Self { + Self { redis } + } +} + +impl SessionStore for RedisSessionStore { + async fn load(&self, session_key: &SessionKey) -> Result, AppError> { + let mut conn = self.redis.get_connection()?; + let value: Option = redis::cmd("GET") + .arg(session_key.as_ref()) + .query(&mut *conn.inner_mut()) + .ok() + .flatten(); + + match value { + None => Ok(None), + Some(v) => Ok(Some(deserialize_session_state(&v)?)), + } + } + + async fn save( + &self, + session_state: SessionState, + ttl_secs: u64, + ) -> Result { + let body = serialize_session_state(&session_state)?; + let session_key = generate_session_key(); + let mut conn = self.redis.get_connection()?; + redis::cmd("SETEX") + .arg(session_key.as_ref()) + .arg(ttl_secs) + .arg(&body) + .query::<()>(&mut *conn.inner_mut())?; + Ok(session_key) + } + + async fn update( + &self, + session_key: SessionKey, + session_state: SessionState, + ttl_secs: u64, + ) -> Result { + let body = serialize_session_state(&session_state)?; + let mut conn = self.redis.get_connection()?; + redis::cmd("SETEX") + .arg(session_key.as_ref()) + .arg(ttl_secs) + .arg(&body) + .query::<()>(&mut *conn.inner_mut())?; + Ok(session_key) + } + + async fn update_ttl(&self, session_key: &SessionKey, ttl_secs: u64) -> Result<(), AppError> { + let mut conn = self.redis.get_connection()?; + redis::cmd("EXPIRE") + .arg(session_key.as_ref()) + .arg(ttl_secs) + .query::<()>(&mut *conn.inner_mut())?; + Ok(()) + } + + async fn delete(&self, session_key: &SessionKey) -> Result<(), AppError> { + let mut conn = self.redis.get_connection()?; + redis::cmd("DEL") + .arg(session_key.as_ref()) + .query::<()>(&mut *conn.inner_mut())?; + Ok(()) + } +} diff --git a/session/storage/session_key.rs b/session/storage/session_key.rs new file mode 100644 index 0000000..f0a2a1d --- /dev/null +++ b/session/storage/session_key.rs @@ -0,0 +1,28 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionKey(pub String); + +impl TryFrom for SessionKey { + type Error = &'static str; + + fn try_from(val: String) -> Result { + if val.len() > 4064 { + return Err("session key exceeds 4064 bytes"); + } + if val.contains('\0') { + return Err("session key contains null bytes"); + } + Ok(SessionKey(val)) + } +} + +impl AsRef for SessionKey { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl From for String { + fn from(key: SessionKey) -> Self { + key.0 + } +} diff --git a/session/storage/utils.rs b/session/storage/utils.rs new file mode 100644 index 0000000..4b070c3 --- /dev/null +++ b/session/storage/utils.rs @@ -0,0 +1,7 @@ +use uuid::Uuid; + +use super::SessionKey; + +pub fn generate_session_key() -> SessionKey { + SessionKey(Uuid::now_v7().to_string()) +} diff --git a/storage/mod.rs b/storage/mod.rs new file mode 100644 index 0000000..7dce405 --- /dev/null +++ b/storage/mod.rs @@ -0,0 +1 @@ +pub mod s3; diff --git a/storage/s3.rs b/storage/s3.rs new file mode 100644 index 0000000..fc4739f --- /dev/null +++ b/storage/s3.rs @@ -0,0 +1,101 @@ +use crate::config::AppConfig; +use crate::error::{AppError, AppResult}; +use object_store::aws::{AmazonS3, AmazonS3Builder}; +use object_store::path::Path; +use object_store::signer::Signer; +use object_store::{ObjectStoreExt, PutPayload}; +use reqwest::Method; +use std::sync::Arc; +use std::time::Duration; + +#[derive(Clone)] +pub struct AppS3Storage { + client: Arc, + #[allow(dead_code)] + bucket: String, + public_url: Option, + presigned_url_expiry: Duration, +} + +impl AppS3Storage { + pub async fn from_config(config: &AppConfig) -> AppResult { + let bucket = config + .s3_bucket()? + .ok_or_else(|| AppError::Config("APP_S3_BUCKET is not set".into()))?; + + let region = config.s3_region()?; + let access_key = config.s3_access_key()?; + let secret_key = config.s3_secret_key()?; + let force_path_style = config.s3_force_path_style()?; + + let mut builder = AmazonS3Builder::new() + .with_bucket_name(&bucket) + .with_region(®ion) + .with_virtual_hosted_style_request(!force_path_style); + + if let Some(endpoint) = config.s3_endpoint()? { + builder = builder.with_endpoint(&endpoint); + } + + if let (Some(ak), Some(sk)) = (access_key, secret_key) { + builder = builder.with_access_key_id(&ak).with_secret_access_key(&sk); + } + + let client = builder.build()?; + + Ok(Self { + client: Arc::new(client), + bucket, + public_url: config.s3_public_url()?, + presigned_url_expiry: Duration::from_secs(config.s3_presigned_url_expiry()?), + }) + } + + pub async fn put(&self, key: &str, data: Vec) -> AppResult<()> { + let path = Path::from(key); + let payload = PutPayload::from_bytes(data.into()); + self.client.put(&path, payload).await?; + Ok(()) + } + + pub async fn get(&self, key: &str) -> AppResult> { + let path = Path::from(key); + let result = self.client.get(&path).await?; + let bytes = result.bytes().await?; + Ok(bytes.to_vec()) + } + + pub async fn delete(&self, key: &str) -> AppResult<()> { + let path = Path::from(key); + self.client.delete(&path).await?; + Ok(()) + } + + pub async fn presigned_get_url( + &self, + key: &str, + expiry: Option, + ) -> AppResult { + let path = Path::from(key); + let expires = expiry.unwrap_or(self.presigned_url_expiry); + let url = self.client.signed_url(Method::GET, &path, expires).await?; + Ok(url.to_string()) + } + + pub async fn presigned_put_url( + &self, + key: &str, + expiry: Option, + ) -> AppResult { + let path = Path::from(key); + let expires = expiry.unwrap_or(self.presigned_url_expiry); + let url = self.client.signed_url(Method::PUT, &path, expires).await?; + Ok(url.to_string()) + } + + pub fn public_url(&self, key: &str) -> Option { + self.public_url + .as_ref() + .map(|base| format!("{}/{}", base.trim_end_matches('/'), key)) + } +}