init codebase
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
FROM clojure:temurin-21-tools-deps AS builder
|
||||
WORKDIR /app
|
||||
COPY deps.edn build.clj ./
|
||||
COPY shared/ shared/
|
||||
COPY api/ api/
|
||||
RUN clj -T:build uber :module api
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/api/target/api.jar app.jar
|
||||
EXPOSE 3001
|
||||
CMD ["java", "-jar", "app.jar"]
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{:tasks
|
||||
{test
|
||||
{:doc "Run all API module tests"
|
||||
:task (shell {:dir ".."} "bb test:api")}
|
||||
|
||||
test:unit
|
||||
{:doc "Run API unit tests only"
|
||||
:task (shell {:dir ".."} "bb test:api:unit")}
|
||||
|
||||
test:integration
|
||||
{:doc "Run API integration tests"
|
||||
:task (shell {:dir ".."} "bb test:api:integration")}}}
|
||||
@@ -0,0 +1,23 @@
|
||||
{:server {:host "0.0.0.0" :port 3001}
|
||||
:db {:host "localhost" :port 5432 :dbname "ajet_chat"
|
||||
:user "ajet" :password "ajet_dev" :pool-size 10
|
||||
:migrations {:enabled true :location "migrations"}}
|
||||
:nats {:url "nats://localhost:4222"
|
||||
:stream-name "ajet-events"
|
||||
:publish-timeout-ms 5000}
|
||||
:minio {:endpoint "http://localhost:9000"
|
||||
:access-key "minioadmin" :secret-key "minioadmin"
|
||||
:bucket "ajet-chat"}
|
||||
:limits {:max-message-length 4000
|
||||
:max-upload-size 10485760
|
||||
:edit-window-minutes 60
|
||||
:default-page-size 50
|
||||
:max-page-size 100}
|
||||
|
||||
:profiles
|
||||
{:test {:db {:host "localhost" :port 5433 :dbname "ajet_chat_test"
|
||||
:password "ajet_test"}
|
||||
:nats {:url "nats://localhost:4223"}
|
||||
:minio {:endpoint "http://localhost:9002"}}
|
||||
:prod {:db {:pool-size 20}
|
||||
:nats {:publish-timeout-ms 10000}}}}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS users;
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username text UNIQUE NOT NULL,
|
||||
display_name text,
|
||||
email text,
|
||||
avatar_url text,
|
||||
status_text text,
|
||||
last_seen_at timestamptz,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS oauth_accounts;
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
provider text NOT NULL,
|
||||
provider_user_id text NOT NULL,
|
||||
provider_username text,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
UNIQUE (provider, provider_user_id)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS communities;
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE IF NOT EXISTS communities (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
slug text UNIQUE NOT NULL,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS community_members;
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS community_members (
|
||||
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
role text NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
|
||||
nickname text,
|
||||
avatar_url text,
|
||||
joined_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (community_id, user_id)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS channel_categories;
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE IF NOT EXISTS channel_categories (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
position int NOT NULL DEFAULT 0
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS channels;
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
community_id uuid REFERENCES communities (id) ON DELETE CASCADE,
|
||||
category_id uuid REFERENCES channel_categories (id) ON DELETE SET NULL,
|
||||
name text NOT NULL,
|
||||
type text NOT NULL CHECK (type IN ('text', 'dm', 'group_dm')),
|
||||
visibility text NOT NULL DEFAULT 'public' CHECK (visibility IN ('public', 'private')),
|
||||
topic text,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS channel_members;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS channel_members (
|
||||
channel_id uuid NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
joined_at timestamptz DEFAULT now(),
|
||||
last_read_message_id uuid,
|
||||
PRIMARY KEY (channel_id, user_id)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS messages;
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id uuid NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
|
||||
user_id uuid REFERENCES users (id) ON DELETE SET NULL,
|
||||
parent_id uuid REFERENCES messages (id) ON DELETE SET NULL,
|
||||
body_md text NOT NULL,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
edited_at timestamptz
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS attachments;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id uuid NOT NULL REFERENCES messages (id) ON DELETE CASCADE,
|
||||
filename text NOT NULL,
|
||||
content_type text NOT NULL,
|
||||
size_bytes bigint NOT NULL,
|
||||
storage_key text NOT NULL
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS reactions;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS reactions (
|
||||
message_id uuid NOT NULL REFERENCES messages (id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
emoji text NOT NULL,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (message_id, user_id, emoji)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS mentions;
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE IF NOT EXISTS mentions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id uuid NOT NULL REFERENCES messages (id) ON DELETE CASCADE,
|
||||
target_type text NOT NULL CHECK (target_type IN ('user', 'channel', 'here')),
|
||||
target_id uuid
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS notifications;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
type text NOT NULL CHECK (type IN ('mention', 'dm', 'thread_reply', 'invite', 'system')),
|
||||
source_id uuid,
|
||||
read boolean DEFAULT false,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
token_hash text NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS api_users;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS api_users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
|
||||
created_by uuid REFERENCES users (id),
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS api_tokens;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
api_user_id uuid NOT NULL REFERENCES api_users (id) ON DELETE CASCADE,
|
||||
token_hash text NOT NULL,
|
||||
scopes text[] DEFAULT '{}',
|
||||
expires_at timestamptz,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS webhooks;
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
|
||||
channel_id uuid NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
avatar_url text,
|
||||
token_hash text NOT NULL,
|
||||
created_by uuid REFERENCES users (id),
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS invites;
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS invites (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
|
||||
created_by uuid REFERENCES users (id),
|
||||
code text UNIQUE NOT NULL,
|
||||
max_uses int,
|
||||
uses int DEFAULT 0,
|
||||
expires_at timestamptz,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
DROP INDEX IF EXISTS idx_messages_channel_created;
|
||||
DROP INDEX IF EXISTS idx_messages_parent;
|
||||
DROP INDEX IF EXISTS idx_messages_search;
|
||||
DROP INDEX IF EXISTS idx_notifications_user_unread;
|
||||
DROP INDEX IF EXISTS idx_channel_members_user;
|
||||
DROP INDEX IF EXISTS idx_community_members_user;
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_channel_created
|
||||
ON messages (channel_id, created_at);
|
||||
--;;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_parent
|
||||
ON messages (parent_id)
|
||||
WHERE parent_id IS NOT NULL;
|
||||
--;;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_search
|
||||
ON messages
|
||||
USING GIN (to_tsvector('english', body_md));
|
||||
--;;
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread
|
||||
ON notifications (user_id, created_at)
|
||||
WHERE read = false;
|
||||
--;;
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_members_user
|
||||
ON channel_members (user_id);
|
||||
--;;
|
||||
CREATE INDEX IF NOT EXISTS idx_community_members_user
|
||||
ON community_members (user_id);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS bans;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS bans (
|
||||
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
reason text,
|
||||
banned_by uuid REFERENCES users (id),
|
||||
created_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (community_id, user_id)
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_mutes_expires;
|
||||
DROP TABLE IF EXISTS mutes;
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS mutes (
|
||||
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
expires_at timestamptz,
|
||||
muted_by uuid REFERENCES users (id),
|
||||
created_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (community_id, user_id)
|
||||
);
|
||||
--;;
|
||||
CREATE INDEX IF NOT EXISTS idx_mutes_expires
|
||||
ON mutes (expires_at)
|
||||
WHERE expires_at IS NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS oauth_providers;
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE oauth_providers (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider_type text NOT NULL CHECK (provider_type IN ('github', 'gitea', 'oidc')),
|
||||
display_name text NOT NULL,
|
||||
slug text UNIQUE NOT NULL,
|
||||
client_id text NOT NULL,
|
||||
client_secret text NOT NULL,
|
||||
base_url text,
|
||||
issuer_url text,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now()
|
||||
);
|
||||
--;;
|
||||
CREATE INDEX idx_oauth_providers_enabled ON oauth_providers (enabled, sort_order);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS system_settings;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE system_settings (
|
||||
key text PRIMARY KEY,
|
||||
value text NOT NULL,
|
||||
updated_at timestamptz DEFAULT now()
|
||||
);
|
||||
--;;
|
||||
INSERT INTO system_settings (key, value) VALUES ('setup_completed', 'false')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@@ -1,5 +1,127 @@
|
||||
(ns ajet.chat.api.core
|
||||
"REST API service — http-kit + reitit.")
|
||||
"REST API service — http-kit + reitit.
|
||||
|
||||
Manages the full lifecycle: DB pool, NATS, MinIO, HTTP server.
|
||||
System state held in a single atom for REPL-driven development."
|
||||
(:refer-clojure :exclude [reset!])
|
||||
(:require [clojure.tools.logging :as log]
|
||||
[org.httpkit.server :as http-kit]
|
||||
[ajet.chat.shared.config :as config]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.shared.storage :as storage]
|
||||
[ajet.chat.api.routes :as routes])
|
||||
(:gen-class))
|
||||
|
||||
(defonce system (atom nil))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Lifecycle
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn start!
|
||||
"Start the API service. Connects to PG, NATS, MinIO and starts HTTP server."
|
||||
[& [{:keys [config-overrides]}]]
|
||||
(when @system
|
||||
(log/warn "System already started — call (stop!) first")
|
||||
(throw (ex-info "System already running" {})))
|
||||
(let [config (config/load-config {:resource "api-config.edn"})
|
||||
config (if config-overrides
|
||||
(merge config config-overrides)
|
||||
config)
|
||||
_ (log/info "Loaded config:" (config/redact config))
|
||||
|
||||
;; Database
|
||||
ds (db/make-datasource (:db config))
|
||||
_ (log/info "Database connection pool created")
|
||||
_ (when (get-in config [:db :migrations :enabled] true)
|
||||
(db/migrate! ds (get-in config [:db :migrations])))
|
||||
|
||||
;; NATS
|
||||
nats (eventbus/connect! (:nats config))
|
||||
_ (log/info "Connected to NATS")
|
||||
_ (eventbus/ensure-stream! nats)
|
||||
|
||||
;; MinIO / S3
|
||||
s3-client (storage/make-client (:minio config))
|
||||
bucket (get-in config [:minio :bucket] "ajet-chat")
|
||||
_ (storage/ensure-bucket! s3-client bucket)
|
||||
_ (log/info "MinIO connected, bucket ensured:" bucket)
|
||||
|
||||
;; System map
|
||||
sys {:config config
|
||||
:ds ds
|
||||
:nats nats
|
||||
:s3 s3-client
|
||||
:bucket bucket}
|
||||
|
||||
;; HTTP server
|
||||
handler (routes/app sys)
|
||||
port (get-in config [:server :port] 3001)
|
||||
host (get-in config [:server :host] "0.0.0.0")
|
||||
server (http-kit/run-server handler {:port port :ip host
|
||||
:max-body (* 12 1024 1024)})]
|
||||
(clojure.core/reset! system (assoc sys :server server :port port))
|
||||
(log/info (str "API service started on port " port))
|
||||
@system))
|
||||
|
||||
(defn stop!
|
||||
"Stop the API service. Shuts down HTTP, NATS, DB pool in order."
|
||||
[]
|
||||
(when-let [sys @system]
|
||||
(log/info "Shutting down API service...")
|
||||
|
||||
;; Stop HTTP server (wait up to 30s for in-flight requests)
|
||||
(when-let [server (:server sys)]
|
||||
(server :timeout 30000)
|
||||
(log/info "HTTP server stopped"))
|
||||
|
||||
;; Close NATS
|
||||
(when-let [nats (:nats sys)]
|
||||
(try
|
||||
(eventbus/close! nats)
|
||||
(log/info "NATS connection closed")
|
||||
(catch Exception e
|
||||
(log/error e "Error closing NATS connection"))))
|
||||
|
||||
;; Close S3 client
|
||||
(when-let [s3 (:s3 sys)]
|
||||
(try
|
||||
(storage/close-client s3)
|
||||
(log/info "S3 client closed")
|
||||
(catch Exception e
|
||||
(log/error e "Error closing S3 client"))))
|
||||
|
||||
;; Close DB pool
|
||||
(when-let [ds (:ds sys)]
|
||||
(try
|
||||
(db/close-datasource ds)
|
||||
(log/info "Database connection pool closed")
|
||||
(catch Exception e
|
||||
(log/error e "Error closing database pool"))))
|
||||
|
||||
(clojure.core/reset! system nil)
|
||||
(log/info "API service stopped")))
|
||||
|
||||
(defn reset!
|
||||
"Stop then start the system (REPL convenience)."
|
||||
[]
|
||||
(stop!)
|
||||
(start!))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Entry point
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn -main [& _args]
|
||||
(println "ajet-chat API starting..."))
|
||||
(start!)
|
||||
|
||||
;; Graceful shutdown hook
|
||||
(.addShutdownHook
|
||||
(Runtime/getRuntime)
|
||||
(Thread. ^Runnable (fn []
|
||||
(log/info "Shutdown hook triggered")
|
||||
(stop!))))
|
||||
|
||||
;; Block main thread
|
||||
@(promise))
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
(ns ajet.chat.api.handlers.admin
|
||||
"Admin endpoints for managing OAuth providers.
|
||||
|
||||
Authorization: user must be owner of at least one community.
|
||||
These endpoints allow post-setup management of OAuth providers
|
||||
stored in the database."
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- require-admin!
|
||||
"Verify the user is an owner of at least one community.
|
||||
Returns the user-id or throws 403."
|
||||
[request]
|
||||
(let [user-id (or (:user-id request)
|
||||
(throw (ex-info "Authentication required"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
ds (get-in request [:system :ds])
|
||||
owner? (db/execute-one! ds
|
||||
{:select [[:1 :exists]]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :user-id [:cast user-id :uuid]]
|
||||
[:= :role "owner"]]})]
|
||||
(when-not owner?
|
||||
(throw (ex-info "Admin access required (must be community owner)"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
user-id))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn list-providers
|
||||
"GET /api/admin/oauth-providers — list all OAuth providers (including disabled)."
|
||||
[request]
|
||||
(require-admin! request)
|
||||
(let [ds (get-in request [:system :ds])
|
||||
providers (db/execute! ds
|
||||
{:select [:id :provider-type :display-name :slug
|
||||
:client-id :base-url :issuer-url
|
||||
:enabled :sort-order :created-at :updated-at]
|
||||
:from [:oauth-providers]
|
||||
:order-by [[:sort-order :asc] [:created-at :asc]]})]
|
||||
(mw/json-response providers)))
|
||||
|
||||
(defn create-provider
|
||||
"POST /api/admin/oauth-providers — create a new OAuth provider."
|
||||
[request]
|
||||
(require-admin! request)
|
||||
(let [ds (get-in request [:system :ds])
|
||||
params (:body-params request)
|
||||
{:keys [provider-type display-name slug client-id client-secret
|
||||
base-url issuer-url enabled sort-order]} params]
|
||||
(when (or (str/blank? provider-type) (str/blank? display-name)
|
||||
(str/blank? slug) (str/blank? client-id) (str/blank? client-secret))
|
||||
(throw (ex-info "provider-type, display-name, slug, client-id, and client-secret are required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
(when-not (#{"github" "gitea" "oidc"} provider-type)
|
||||
(throw (ex-info "provider-type must be github, gitea, or oidc"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
(let [provider (db/execute-one! ds
|
||||
{:insert-into :oauth-providers
|
||||
:values [(cond-> {:provider-type provider-type
|
||||
:display-name display-name
|
||||
:slug slug
|
||||
:client-id client-id
|
||||
:client-secret client-secret}
|
||||
base-url (assoc :base-url base-url)
|
||||
issuer-url (assoc :issuer-url issuer-url)
|
||||
(some? enabled) (assoc :enabled enabled)
|
||||
(some? sort-order) (assoc :sort-order sort-order))]
|
||||
:returning [:id :provider-type :display-name :slug
|
||||
:client-id :base-url :issuer-url
|
||||
:enabled :sort-order :created-at :updated-at]})]
|
||||
(log/info "Admin created OAuth provider" slug provider-type)
|
||||
(mw/json-response 201 provider))))
|
||||
|
||||
(defn update-provider
|
||||
"PUT /api/admin/oauth-providers/:id — update an existing OAuth provider."
|
||||
[request]
|
||||
(require-admin! request)
|
||||
(let [ds (get-in request [:system :ds])
|
||||
provider-id (get-in request [:path-params :id])
|
||||
params (:body-params request)
|
||||
updates (cond-> {}
|
||||
(:display-name params) (assoc :display-name (:display-name params))
|
||||
(:slug params) (assoc :slug (:slug params))
|
||||
(:client-id params) (assoc :client-id (:client-id params))
|
||||
(:client-secret params) (assoc :client-secret (:client-secret params))
|
||||
(:base-url params) (assoc :base-url (:base-url params))
|
||||
(:issuer-url params) (assoc :issuer-url (:issuer-url params))
|
||||
(some? (:enabled params)) (assoc :enabled (:enabled params))
|
||||
(some? (:sort-order params)) (assoc :sort-order (:sort-order params)))]
|
||||
(when (empty? updates)
|
||||
(throw (ex-info "No fields to update" {:type :ajet.chat/validation-error})))
|
||||
(let [result (db/execute-one! ds
|
||||
{:update :oauth-providers
|
||||
:set (assoc updates :updated-at [:now])
|
||||
:where [:= :id [:cast provider-id :uuid]]
|
||||
:returning [:id :provider-type :display-name :slug
|
||||
:client-id :base-url :issuer-url
|
||||
:enabled :sort-order :created-at :updated-at]})]
|
||||
(if result
|
||||
(do (log/info "Admin updated OAuth provider" provider-id)
|
||||
(mw/json-response result))
|
||||
(mw/error-response 404 "NOT_FOUND" "OAuth provider not found")))))
|
||||
|
||||
(defn delete-provider
|
||||
"DELETE /api/admin/oauth-providers/:id — delete an OAuth provider."
|
||||
[request]
|
||||
(require-admin! request)
|
||||
(let [ds (get-in request [:system :ds])
|
||||
provider-id (get-in request [:path-params :id])]
|
||||
(db/execute! ds
|
||||
{:delete-from :oauth-providers
|
||||
:where [:= :id [:cast provider-id :uuid]]})
|
||||
(log/info "Admin deleted OAuth provider" provider-id)
|
||||
(mw/json-response 204 nil)))
|
||||
@@ -0,0 +1,149 @@
|
||||
(ns ajet.chat.api.handlers.categories
|
||||
"Channel category CRUD handlers.
|
||||
|
||||
Categories organize channels within a community. Each has a name
|
||||
and a position for ordering. Deleting a category sets channels
|
||||
in it to uncategorized (category_id = NULL)."
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- uuid [] (str (UUID/randomUUID)))
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-membership! [ds community-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this community"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-role! [ds community-id user-id required-role]
|
||||
(let [member (check-membership! ds community-id user-id)
|
||||
role (:role member)
|
||||
hierarchy {"owner" 3 "admin" 2 "member" 1}
|
||||
has-level (get hierarchy role 0)
|
||||
need-level (get hierarchy required-role 0)]
|
||||
(when (< has-level need-level)
|
||||
(throw (ex-info (str "Requires " required-role " role or higher")
|
||||
{:type :ajet.chat/forbidden})))
|
||||
member))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn list-categories
|
||||
"GET /api/communities/:cid/categories
|
||||
Returns categories ordered by position."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
_ (check-membership! ds community-id user-id)
|
||||
categories (db/execute! ds
|
||||
{:select [:*]
|
||||
:from [:channel-categories]
|
||||
:where [:= :community-id [:cast community-id :uuid]]
|
||||
:order-by [[:position :asc]]})]
|
||||
(mw/json-response categories)))
|
||||
|
||||
(defn create-category
|
||||
"POST /api/communities/:cid/categories
|
||||
Creates a new category. Admin+ only."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
params (:body-params request)
|
||||
name-v (:name params)
|
||||
position (or (:position params) 0)]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
(when (str/blank? name-v)
|
||||
(throw (ex-info "Category name is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
(let [category-id (uuid)]
|
||||
(db/execute! ds
|
||||
{:insert-into :channel-categories
|
||||
:values [{:id [:cast category-id :uuid]
|
||||
:community-id [:cast community-id :uuid]
|
||||
:name name-v
|
||||
:position position}]})
|
||||
|
||||
(let [category (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channel-categories]
|
||||
:where [:= :id [:cast category-id :uuid]]})]
|
||||
(mw/json-response 201 category)))))
|
||||
|
||||
(defn update-category
|
||||
"PUT /api/categories/:id
|
||||
Updates category name and/or position. Admin+ only."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
category-id (get-in request [:path-params :id])
|
||||
params (:body-params request)
|
||||
category (or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channel-categories]
|
||||
:where [:= :id [:cast category-id :uuid]]})
|
||||
(throw (ex-info "Category not found"
|
||||
{:type :ajet.chat/not-found})))
|
||||
community-id (str (:community-id category))]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
(let [updates (cond-> {}
|
||||
(:name params) (assoc :name (:name params))
|
||||
(contains? params :position) (assoc :position (:position params)))]
|
||||
(when (seq updates)
|
||||
(db/execute! ds
|
||||
{:update :channel-categories
|
||||
:set updates
|
||||
:where [:= :id [:cast category-id :uuid]]}))
|
||||
|
||||
(let [updated (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channel-categories]
|
||||
:where [:= :id [:cast category-id :uuid]]})]
|
||||
(mw/json-response updated)))))
|
||||
|
||||
(defn delete-category
|
||||
"DELETE /api/categories/:id
|
||||
Deletes category. Channels become uncategorized (ON DELETE SET NULL). Admin+ only."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
category-id (get-in request [:path-params :id])
|
||||
category (or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channel-categories]
|
||||
:where [:= :id [:cast category-id :uuid]]})
|
||||
(throw (ex-info "Category not found"
|
||||
{:type :ajet.chat/not-found})))
|
||||
community-id (str (:community-id category))]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
;; ON DELETE SET NULL on channels.category_id handles the uncategorizing
|
||||
(db/execute! ds
|
||||
{:delete-from :channel-categories
|
||||
:where [:= :id [:cast category-id :uuid]]})
|
||||
|
||||
(mw/json-response 204 nil)))
|
||||
@@ -0,0 +1,331 @@
|
||||
(ns ajet.chat.api.handlers.channels
|
||||
"Channel CRUD, join/leave, and member listing handlers.
|
||||
|
||||
Channels belong to a community. DM channels have nil community_id
|
||||
and are handled separately in the DMs handler."
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- uuid [] (str (UUID/randomUUID)))
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-membership! [ds community-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this community"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-role! [ds community-id user-id required-role]
|
||||
(let [member (check-membership! ds community-id user-id)
|
||||
role (:role member)
|
||||
hierarchy {"owner" 3 "admin" 2 "member" 1}
|
||||
has-level (get hierarchy role 0)
|
||||
need-level (get hierarchy required-role 0)]
|
||||
(when (< has-level need-level)
|
||||
(throw (ex-info (str "Requires " required-role " role or higher")
|
||||
{:type :ajet.chat/forbidden})))
|
||||
member))
|
||||
|
||||
(defn- check-channel-member! [ds channel-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channel-members]
|
||||
:where [:and
|
||||
[:= :channel-id [:cast channel-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this channel"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- get-channel-row [ds channel-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channels]
|
||||
:where [:= :id [:cast channel-id :uuid]]})
|
||||
(throw (ex-info "Channel not found" {:type :ajet.chat/not-found}))))
|
||||
|
||||
(defn- publish-event! [nats subject event-type payload]
|
||||
(try
|
||||
(eventbus/publish! nats subject event-type payload)
|
||||
(catch Exception e
|
||||
(log/error e "Failed to publish event" event-type "to" subject))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn list-channels
|
||||
"GET /api/communities/:cid/channels
|
||||
Returns public channels + private channels user is a member of."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
_ (check-membership! ds community-id user-id)
|
||||
channels (db/execute! ds
|
||||
{:select-distinct [:c.*]
|
||||
:from [[:channels :c]]
|
||||
:left-join [[:channel-members :cm]
|
||||
[:and
|
||||
[:= :cm.channel-id :c.id]
|
||||
[:= :cm.user-id [:cast user-id :uuid]]]]
|
||||
:where [:and
|
||||
[:= :c.community-id [:cast community-id :uuid]]
|
||||
[:or
|
||||
[:= :c.visibility "public"]
|
||||
[:!= :cm.user-id nil]]]
|
||||
:order-by [[:c.name :asc]]})]
|
||||
(mw/json-response channels)))
|
||||
|
||||
(defn create-channel
|
||||
"POST /api/communities/:cid/channels
|
||||
Creates a channel. Admin+ only. Adds creator as member."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
params (:body-params request)
|
||||
name-v (:name params)
|
||||
ch-type (or (:type params) "text")
|
||||
visibility (or (:visibility params) "public")
|
||||
topic (:topic params)
|
||||
category-id (:category_id params)]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
(when (str/blank? name-v)
|
||||
(throw (ex-info "Channel name is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Check for duplicate name in community
|
||||
(when (db/execute-one! ds
|
||||
{:select [:id]
|
||||
:from [:channels]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :name name-v]]})
|
||||
(throw (ex-info "A channel with this name already exists in this community"
|
||||
{:type :ajet.chat/conflict})))
|
||||
|
||||
(let [channel-id (uuid)]
|
||||
(db/with-transaction [tx ds]
|
||||
(db/execute! tx
|
||||
{:insert-into :channels
|
||||
:values [(cond-> {:id [:cast channel-id :uuid]
|
||||
:community-id [:cast community-id :uuid]
|
||||
:name name-v
|
||||
:type ch-type
|
||||
:visibility visibility}
|
||||
topic (assoc :topic topic)
|
||||
category-id (assoc :category-id [:cast category-id :uuid]))]})
|
||||
|
||||
;; Add creator as channel member
|
||||
(db/execute! tx
|
||||
{:insert-into :channel-members
|
||||
:values [{:channel-id [:cast channel-id :uuid]
|
||||
:user-id [:cast user-id :uuid]}]}))
|
||||
|
||||
(let [channel (get-channel-row ds channel-id)]
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:channel/created
|
||||
{:channel-id channel-id
|
||||
:community-id community-id
|
||||
:name name-v
|
||||
:type ch-type
|
||||
:visibility visibility
|
||||
:created-by user-id})
|
||||
|
||||
(mw/json-response 201 channel)))))
|
||||
|
||||
(defn get-channel
|
||||
"GET /api/channels/:id
|
||||
Returns channel details. Accessible to channel members, or community members for public channels."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
channel-id (get-in request [:path-params :id])
|
||||
channel (get-channel-row ds channel-id)]
|
||||
|
||||
;; Access check: channel member OR (public channel + community member)
|
||||
(let [is-channel-member (db/execute-one! ds
|
||||
{:select [:user-id]
|
||||
:from [:channel-members]
|
||||
:where [:and
|
||||
[:= :channel-id [:cast channel-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})]
|
||||
(when-not is-channel-member
|
||||
(if (and (= "public" (:visibility channel))
|
||||
(:community-id channel))
|
||||
(check-membership! ds (str (:community-id channel)) user-id)
|
||||
(throw (ex-info "Not a member of this channel"
|
||||
{:type :ajet.chat/forbidden})))))
|
||||
|
||||
(mw/json-response channel)))
|
||||
|
||||
(defn update-channel
|
||||
"PUT /api/channels/:id
|
||||
Updates channel name, topic, category. Admin+ of the community."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
channel-id (get-in request [:path-params :id])
|
||||
params (:body-params request)
|
||||
channel (get-channel-row ds channel-id)
|
||||
community-id (str (:community-id channel))]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
(let [updates (cond-> {}
|
||||
(:name params) (assoc :name (:name params))
|
||||
(:topic params) (assoc :topic (:topic params))
|
||||
(contains? params :category_id)
|
||||
(assoc :category-id (when (:category_id params)
|
||||
[:cast (:category_id params) :uuid]))
|
||||
(:visibility params) (assoc :visibility (:visibility params)))]
|
||||
(when (seq updates)
|
||||
(db/execute! ds
|
||||
{:update :channels
|
||||
:set updates
|
||||
:where [:= :id [:cast channel-id :uuid]]}))
|
||||
|
||||
(let [updated (get-channel-row ds channel-id)]
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:channel/updated
|
||||
{:channel-id channel-id
|
||||
:community-id community-id
|
||||
:updated-by user-id
|
||||
:changes (dissoc updates :category-id)})
|
||||
|
||||
(mw/json-response updated)))))
|
||||
|
||||
(defn delete-channel
|
||||
"DELETE /api/channels/:id
|
||||
Deletes channel and all messages. Admin+ of the community."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
channel-id (get-in request [:path-params :id])
|
||||
channel (get-channel-row ds channel-id)
|
||||
community-id (str (:community-id channel))]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
;; CASCADE handles cleanup of messages, members, etc.
|
||||
(db/execute! ds
|
||||
{:delete-from :channels
|
||||
:where [:= :id [:cast channel-id :uuid]]})
|
||||
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:channel/deleted
|
||||
{:channel-id channel-id
|
||||
:community-id community-id
|
||||
:name (:name channel)
|
||||
:deleted-by user-id})
|
||||
|
||||
(mw/json-response 204 nil)))
|
||||
|
||||
(defn join-channel
|
||||
"POST /api/channels/:id/join
|
||||
Joins a public channel. Community membership required."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
channel-id (get-in request [:path-params :id])
|
||||
channel (get-channel-row ds channel-id)
|
||||
community-id (str (:community-id channel))]
|
||||
|
||||
(check-membership! ds community-id user-id)
|
||||
|
||||
(when (= "private" (:visibility channel))
|
||||
(throw (ex-info "Cannot join a private channel without an invite"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
|
||||
;; Idempotent: ignore if already a member
|
||||
(let [existing (db/execute-one! ds
|
||||
{:select [:user-id]
|
||||
:from [:channel-members]
|
||||
:where [:and
|
||||
[:= :channel-id [:cast channel-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})]
|
||||
(when-not existing
|
||||
(db/execute! ds
|
||||
{:insert-into :channel-members
|
||||
:values [{:channel-id [:cast channel-id :uuid]
|
||||
:user-id [:cast user-id :uuid]}]})
|
||||
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:member/joined
|
||||
{:channel-id channel-id
|
||||
:community-id community-id
|
||||
:user-id user-id})))
|
||||
|
||||
(mw/json-response {:status "joined" :channel-id channel-id})))
|
||||
|
||||
(defn leave-channel
|
||||
"POST /api/channels/:id/leave
|
||||
Leaves a channel."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
channel-id (get-in request [:path-params :id])
|
||||
channel (get-channel-row ds channel-id)
|
||||
community-id (when (:community-id channel)
|
||||
(str (:community-id channel)))]
|
||||
|
||||
(db/execute! ds
|
||||
{:delete-from :channel-members
|
||||
:where [:and
|
||||
[:= :channel-id [:cast channel-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
|
||||
(when community-id
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:member/left
|
||||
{:channel-id channel-id
|
||||
:community-id community-id
|
||||
:user-id user-id}))
|
||||
|
||||
(mw/json-response {:status "left" :channel-id channel-id})))
|
||||
|
||||
(defn list-channel-members
|
||||
"GET /api/channels/:id/members
|
||||
Lists members of a channel. Requires channel membership."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
channel-id (get-in request [:path-params :id])]
|
||||
|
||||
(check-channel-member! ds channel-id user-id)
|
||||
|
||||
(let [members (db/execute! ds
|
||||
{:select [:u.id :u.username :u.display-name :u.avatar-url
|
||||
:u.status-text :cm.joined-at]
|
||||
:from [[:channel-members :cm]]
|
||||
:join [[:users :u] [:= :u.id :cm.user-id]]
|
||||
:where [:= :cm.channel-id [:cast channel-id :uuid]]
|
||||
:order-by [[:u.username :asc]]})]
|
||||
(mw/json-response members))))
|
||||
@@ -0,0 +1,451 @@
|
||||
(ns ajet.chat.api.handlers.commands
|
||||
"Slash command dispatcher.
|
||||
|
||||
Parses command strings and dispatches to the appropriate handler.
|
||||
Commands: /help, /topic, /nick, /invite, /kick, /ban, /mute,
|
||||
/token, /webhook, /status"
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]
|
||||
[java.security SecureRandom MessageDigest]
|
||||
[java.util Base64]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- uuid [] (str (UUID/randomUUID)))
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-role! [ds community-id user-id required-role]
|
||||
(let [member (or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this community"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
role (:role member)
|
||||
hierarchy {"owner" 3 "admin" 2 "member" 1}
|
||||
has-level (get hierarchy role 0)
|
||||
need-level (get hierarchy required-role 0)]
|
||||
(when (< has-level need-level)
|
||||
(throw (ex-info (str "Requires " required-role " role or higher")
|
||||
{:type :ajet.chat/forbidden})))
|
||||
member))
|
||||
|
||||
(defn- parse-user-mention
|
||||
"Extract user UUID from @<user:uuid> syntax."
|
||||
[s]
|
||||
(when-let [[_ uid] (re-matches #"@<user:([0-9a-f-]+)>" s)]
|
||||
uid))
|
||||
|
||||
(defn- publish-event! [nats subject event-type payload]
|
||||
(try
|
||||
(eventbus/publish! nats subject event-type payload)
|
||||
(catch Exception e
|
||||
(log/error e "Failed to publish event" event-type "to" subject))))
|
||||
|
||||
(defn- generate-token []
|
||||
(let [bytes (byte-array 32)
|
||||
sr (SecureRandom.)]
|
||||
(.nextBytes sr bytes)
|
||||
(.encodeToString (Base64/getUrlEncoder) bytes)))
|
||||
|
||||
(defn- hash-token [token]
|
||||
(let [md (MessageDigest/getInstance "SHA-256")
|
||||
bytes (.digest md (.getBytes token "UTF-8"))]
|
||||
(.encodeToString (Base64/getUrlEncoder) bytes)))
|
||||
|
||||
(defn- generate-invite-code []
|
||||
(let [chars "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
sr (SecureRandom.)
|
||||
sb (StringBuilder. 8)]
|
||||
(dotimes [_ 8]
|
||||
(.append sb (.charAt chars (.nextInt sr (count chars)))))
|
||||
(.toString sb)))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Command Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private help-text
|
||||
{"help" {:description "Show available commands" :usage "/help [command]" :permission "All"}
|
||||
"topic" {:description "Set channel topic" :usage "/topic <text>" :permission "Admin+"}
|
||||
"nick" {:description "Set community nickname" :usage "/nick <nickname>" :permission "All"}
|
||||
"invite" {:description "Generate invite link" :usage "/invite [max_uses] [expires_hours]" :permission "Admin+"}
|
||||
"kick" {:description "Kick user from community" :usage "/kick @<user>" :permission "Admin+"}
|
||||
"ban" {:description "Ban user from community" :usage "/ban @<user>" :permission "Admin+"}
|
||||
"mute" {:description "Mute user for duration" :usage "/mute @<user> <duration>" :permission "Admin+"}
|
||||
"token" {:description "Manage API tokens" :usage "/token create|revoke|list" :permission "Owner"}
|
||||
"webhook" {:description "Manage webhooks" :usage "/webhook create|delete|list" :permission "Admin+"}
|
||||
"status" {:description "Set status text" :usage "/status <text>" :permission "All"}})
|
||||
|
||||
(defn- cmd-help [_ds _nats _user-id _community-id _channel-id args]
|
||||
(if (and (seq args) (get help-text (first args)))
|
||||
(let [cmd (get help-text (first args))]
|
||||
{:result (str "**/" (first args) "** - " (:description cmd)
|
||||
"\nUsage: `" (:usage cmd) "`"
|
||||
"\nPermission: " (:permission cmd))})
|
||||
{:result (str "**Available commands:**\n"
|
||||
(str/join "\n"
|
||||
(map (fn [[name {:keys [description]}]]
|
||||
(str " `/" name "` - " description))
|
||||
(sort-by key help-text))))}))
|
||||
|
||||
(defn- cmd-topic [ds nats user-id community-id channel-id args]
|
||||
(check-role! ds community-id user-id "admin")
|
||||
(when (empty? args)
|
||||
(throw (ex-info "Usage: /topic <text>" {:type :ajet.chat/validation-error})))
|
||||
(let [topic (str/join " " args)]
|
||||
(db/execute! ds
|
||||
{:update :channels
|
||||
:set {:topic topic}
|
||||
:where [:= :id [:cast channel-id :uuid]]})
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:channel/updated
|
||||
{:channel-id channel-id :topic topic :updated-by user-id})
|
||||
{:result (str "Channel topic set to: " topic)}))
|
||||
|
||||
(defn- cmd-nick [ds _nats user-id community-id _channel-id args]
|
||||
(when (empty? args)
|
||||
(throw (ex-info "Usage: /nick <nickname>" {:type :ajet.chat/validation-error})))
|
||||
(let [nickname (str/join " " args)]
|
||||
(db/execute! ds
|
||||
{:update :community-members
|
||||
:set {:nickname nickname}
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
{:result (str "Nickname set to: " nickname)}))
|
||||
|
||||
(defn- cmd-invite [ds _nats user-id community-id _channel-id args]
|
||||
(check-role! ds community-id user-id "admin")
|
||||
(let [max-uses (some-> (first args) parse-long)
|
||||
expires-hrs (some-> (second args) parse-long)
|
||||
invite-id (uuid)
|
||||
code (generate-invite-code)
|
||||
values (cond-> {:id [:cast invite-id :uuid]
|
||||
:community-id [:cast community-id :uuid]
|
||||
:created-by [:cast user-id :uuid]
|
||||
:code code}
|
||||
max-uses (assoc :max-uses max-uses)
|
||||
expires-hrs (assoc :expires-at
|
||||
[:raw (str "now() + interval '" expires-hrs " hours'")]))]
|
||||
(db/execute! ds
|
||||
{:insert-into :invites
|
||||
:values [values]})
|
||||
{:result (str "Invite created: `/invite/" code "`"
|
||||
(when max-uses (str " (max " max-uses " uses)"))
|
||||
(when expires-hrs (str " (expires in " expires-hrs "h)")))}))
|
||||
|
||||
(defn- cmd-kick [ds nats user-id community-id _channel-id args]
|
||||
(check-role! ds community-id user-id "admin")
|
||||
(when (empty? args)
|
||||
(throw (ex-info "Usage: /kick @<user>" {:type :ajet.chat/validation-error})))
|
||||
(let [target-uid (or (parse-user-mention (first args))
|
||||
(throw (ex-info "Invalid user mention. Use @<user:uuid> format."
|
||||
{:type :ajet.chat/validation-error})))
|
||||
target (or (db/execute-one! ds
|
||||
{:select [:role]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast target-uid :uuid]]]})
|
||||
(throw (ex-info "User is not a member of this community"
|
||||
{:type :ajet.chat/not-found})))]
|
||||
(when (= (:role target) "owner")
|
||||
(throw (ex-info "Cannot kick the community owner"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
;; Remove from channels
|
||||
(db/execute-sql! ds
|
||||
["DELETE FROM channel_members WHERE user_id = ?::uuid AND channel_id IN (SELECT id FROM channels WHERE community_id = ?::uuid)"
|
||||
target-uid community-id])
|
||||
;; Remove community membership
|
||||
(db/execute! ds
|
||||
{:delete-from :community-members
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast target-uid :uuid]]]})
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:member/kicked
|
||||
{:community-id community-id :user-id target-uid :kicked-by user-id})
|
||||
{:result (str "User " target-uid " has been kicked from the community.")}))
|
||||
|
||||
(defn- cmd-ban [ds nats user-id community-id _channel-id args]
|
||||
(check-role! ds community-id user-id "admin")
|
||||
(when (empty? args)
|
||||
(throw (ex-info "Usage: /ban @<user>" {:type :ajet.chat/validation-error})))
|
||||
(let [target-uid (or (parse-user-mention (first args))
|
||||
(throw (ex-info "Invalid user mention. Use @<user:uuid> format."
|
||||
{:type :ajet.chat/validation-error})))
|
||||
target (db/execute-one! ds
|
||||
{:select [:role]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast target-uid :uuid]]]})]
|
||||
(when (and target (= (:role target) "owner"))
|
||||
(throw (ex-info "Cannot ban the community owner"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
(let [reason (when (> (count args) 1) (str/join " " (rest args)))]
|
||||
;; Create ban record
|
||||
(db/execute! ds
|
||||
{:insert-into :bans
|
||||
:values [(cond-> {:community-id [:cast community-id :uuid]
|
||||
:user-id [:cast target-uid :uuid]
|
||||
:banned-by [:cast user-id :uuid]}
|
||||
reason (assoc :reason reason))]
|
||||
:on-conflict [:community-id :user-id]
|
||||
:do-nothing true})
|
||||
;; Remove from channels
|
||||
(db/execute-sql! ds
|
||||
["DELETE FROM channel_members WHERE user_id = ?::uuid AND channel_id IN (SELECT id FROM channels WHERE community_id = ?::uuid)"
|
||||
target-uid community-id])
|
||||
;; Remove community membership
|
||||
(db/execute! ds
|
||||
{:delete-from :community-members
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast target-uid :uuid]]]})
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:member/banned
|
||||
{:community-id community-id :user-id target-uid :banned-by user-id :reason reason})
|
||||
{:result (str "User " target-uid " has been banned from the community.")})))
|
||||
|
||||
(defn- parse-duration
|
||||
"Parse duration string like '10m', '1h', '24h', '7d' to SQL interval."
|
||||
[s]
|
||||
(when-let [[_ n unit] (re-matches #"(\d+)(m|h|d)" s)]
|
||||
(let [unit-name (case unit "m" "minutes" "h" "hours" "d" "days")]
|
||||
(str n " " unit-name))))
|
||||
|
||||
(defn- cmd-mute [ds nats user-id community-id _channel-id args]
|
||||
(check-role! ds community-id user-id "admin")
|
||||
(when (< (count args) 2)
|
||||
(throw (ex-info "Usage: /mute @<user> <duration> (e.g. 10m, 1h, 24h, 7d)"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
(let [target-uid (or (parse-user-mention (first args))
|
||||
(throw (ex-info "Invalid user mention. Use @<user:uuid> format."
|
||||
{:type :ajet.chat/validation-error})))
|
||||
duration-str (second args)
|
||||
interval (or (parse-duration duration-str)
|
||||
(throw (ex-info "Invalid duration. Use format like: 10m, 1h, 24h, 7d"
|
||||
{:type :ajet.chat/validation-error})))]
|
||||
;; Check target is not owner
|
||||
(let [target (db/execute-one! ds
|
||||
{:select [:role]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast target-uid :uuid]]]})]
|
||||
(when (and target (= (:role target) "owner"))
|
||||
(throw (ex-info "Cannot mute the community owner"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
;; Upsert mute record
|
||||
(db/execute-sql! ds
|
||||
[(str "INSERT INTO mutes (community_id, user_id, expires_at, muted_by) "
|
||||
"VALUES (?::uuid, ?::uuid, now() + interval '" interval "', ?::uuid) "
|
||||
"ON CONFLICT (community_id, user_id) "
|
||||
"DO UPDATE SET expires_at = now() + interval '" interval "', muted_by = ?::uuid")
|
||||
community-id target-uid user-id user-id])
|
||||
|
||||
{:result (str "User " target-uid " has been muted for " duration-str ".")}))
|
||||
|
||||
(defn- cmd-token [ds _nats user-id community-id _channel-id args]
|
||||
(check-role! ds community-id user-id "owner")
|
||||
(let [sub-cmd (first args)]
|
||||
(case sub-cmd
|
||||
"create"
|
||||
(let [name-v (or (second args)
|
||||
(throw (ex-info "Usage: /token create <name>"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
api-user-id (uuid)
|
||||
token-id (uuid)
|
||||
token (generate-token)
|
||||
token-h (hash-token token)]
|
||||
(db/with-transaction [tx ds]
|
||||
(db/execute! tx
|
||||
{:insert-into :api-users
|
||||
:values [{:id [:cast api-user-id :uuid]
|
||||
:name name-v
|
||||
:community-id [:cast community-id :uuid]
|
||||
:created-by [:cast user-id :uuid]}]})
|
||||
(db/execute! tx
|
||||
{:insert-into :api-tokens
|
||||
:values [{:id [:cast token-id :uuid]
|
||||
:api-user-id [:cast api-user-id :uuid]
|
||||
:token-hash token-h}]}))
|
||||
{:result (str "API token created for **" name-v "**\n"
|
||||
"Token: `" token "`\n"
|
||||
"This token will only be shown once. Save it securely.")})
|
||||
|
||||
"revoke"
|
||||
(let [name-v (or (second args)
|
||||
(throw (ex-info "Usage: /token revoke <name>"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
api-user (db/execute-one! ds
|
||||
{:select [:id]
|
||||
:from [:api-users]
|
||||
:where [:and
|
||||
[:= :name name-v]
|
||||
[:= :community-id [:cast community-id :uuid]]]})]
|
||||
(when-not api-user
|
||||
(throw (ex-info (str "API user not found: " name-v)
|
||||
{:type :ajet.chat/not-found})))
|
||||
;; Delete the api_user (CASCADE deletes tokens)
|
||||
(db/execute! ds
|
||||
{:delete-from :api-users
|
||||
:where [:= :id (:id api-user)]})
|
||||
{:result (str "API token for **" name-v "** has been revoked.")})
|
||||
|
||||
"list"
|
||||
(let [api-users (db/execute! ds
|
||||
{:select [:au.id :au.name :au.created-at]
|
||||
:from [[:api-users :au]]
|
||||
:where [:= :au.community-id [:cast community-id :uuid]]
|
||||
:order-by [[:au.created-at :desc]]})]
|
||||
(if (empty? api-users)
|
||||
{:result "No API tokens configured."}
|
||||
{:result (str "**API Tokens:**\n"
|
||||
(str/join "\n" (map #(str " - " (:name %) " (created " (:created-at %) ")")
|
||||
api-users)))}))
|
||||
|
||||
;; default
|
||||
(throw (ex-info "Usage: /token create|revoke|list <name>"
|
||||
{:type :ajet.chat/validation-error})))))
|
||||
|
||||
(defn- cmd-webhook [ds _nats user-id community-id channel-id args]
|
||||
(check-role! ds community-id user-id "admin")
|
||||
(let [sub-cmd (first args)]
|
||||
(case sub-cmd
|
||||
"create"
|
||||
(let [name-v (or (second args)
|
||||
(throw (ex-info "Usage: /webhook create <name> [channel-id]"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
target-ch (or (nth args 2 nil) channel-id)
|
||||
webhook-id (uuid)
|
||||
token (generate-token)
|
||||
token-h (hash-token token)]
|
||||
(db/execute! ds
|
||||
{:insert-into :webhooks
|
||||
:values [{:id [:cast webhook-id :uuid]
|
||||
:community-id [:cast community-id :uuid]
|
||||
:channel-id [:cast target-ch :uuid]
|
||||
:name name-v
|
||||
:token-hash token-h
|
||||
:created-by [:cast user-id :uuid]}]})
|
||||
{:result (str "Webhook **" name-v "** created.\n"
|
||||
"URL: `/api/webhooks/" webhook-id "/incoming`\n"
|
||||
"Token: `" token "`\n"
|
||||
"This token will only be shown once.")})
|
||||
|
||||
"delete"
|
||||
(let [name-v (or (second args)
|
||||
(throw (ex-info "Usage: /webhook delete <name>"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
webhook (db/execute-one! ds
|
||||
{:select [:id]
|
||||
:from [:webhooks]
|
||||
:where [:and
|
||||
[:= :name name-v]
|
||||
[:= :community-id [:cast community-id :uuid]]]})]
|
||||
(when-not webhook
|
||||
(throw (ex-info (str "Webhook not found: " name-v)
|
||||
{:type :ajet.chat/not-found})))
|
||||
(db/execute! ds
|
||||
{:delete-from :webhooks
|
||||
:where [:= :id (:id webhook)]})
|
||||
{:result (str "Webhook **" name-v "** deleted.")})
|
||||
|
||||
"list"
|
||||
(let [webhooks (db/execute! ds
|
||||
{:select [:name :channel-id :created-at]
|
||||
:from [:webhooks]
|
||||
:where [:= :community-id [:cast community-id :uuid]]
|
||||
:order-by [[:created-at :desc]]})]
|
||||
(if (empty? webhooks)
|
||||
{:result "No webhooks configured."}
|
||||
{:result (str "**Webhooks:**\n"
|
||||
(str/join "\n" (map #(str " - " (:name %)
|
||||
" -> channel " (:channel-id %))
|
||||
webhooks)))}))
|
||||
|
||||
;; default
|
||||
(throw (ex-info "Usage: /webhook create|delete|list <name>"
|
||||
{:type :ajet.chat/validation-error})))))
|
||||
|
||||
(defn- cmd-status [ds _nats user-id _community-id _channel-id args]
|
||||
(let [status-text (str/join " " args)]
|
||||
(db/execute! ds
|
||||
{:update :users
|
||||
:set {:status-text (when (seq status-text) status-text)}
|
||||
:where [:= :id [:cast user-id :uuid]]})
|
||||
{:result (if (seq status-text)
|
||||
(str "Status set to: " status-text)
|
||||
"Status cleared.")}))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Dispatcher
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private command-handlers
|
||||
{"help" cmd-help
|
||||
"topic" cmd-topic
|
||||
"nick" cmd-nick
|
||||
"invite" cmd-invite
|
||||
"kick" cmd-kick
|
||||
"ban" cmd-ban
|
||||
"mute" cmd-mute
|
||||
"token" cmd-token
|
||||
"webhook" cmd-webhook
|
||||
"status" cmd-status})
|
||||
|
||||
(defn execute-command
|
||||
"POST /api/commands
|
||||
Parses and dispatches a slash command.
|
||||
Body: {\"command\": \"/kick @<user:uuid>\", \"channel_id\": \"uuid\", \"community_id\": \"uuid\"}"
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
params (:body-params request)
|
||||
command-str (:command params)
|
||||
channel-id (:channel_id params)
|
||||
community-id (:community_id params)]
|
||||
|
||||
(when (str/blank? command-str)
|
||||
(throw (ex-info "command is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
(when (str/blank? community-id)
|
||||
(throw (ex-info "community_id is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Parse command: "/cmd arg1 arg2 ..."
|
||||
(let [parts (str/split (str/trim command-str) #"\s+")
|
||||
cmd-name (when (str/starts-with? (first parts) "/")
|
||||
(subs (first parts) 1))
|
||||
args (rest parts)]
|
||||
|
||||
(when-not cmd-name
|
||||
(throw (ex-info "Command must start with /"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
(if-let [handler-fn (get command-handlers cmd-name)]
|
||||
(let [result (handler-fn ds nats user-id community-id channel-id args)]
|
||||
(mw/json-response result))
|
||||
|
||||
;; Unknown command
|
||||
(mw/json-response {:result (str "Unknown command: /" cmd-name ". Type /help for available commands.")})))))
|
||||
@@ -0,0 +1,242 @@
|
||||
(ns ajet.chat.api.handlers.communities
|
||||
"Community CRUD handlers.
|
||||
|
||||
Communities are the top-level organizational unit. Creating a community
|
||||
makes the creator the owner and bootstraps a #general channel."
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- uuid []
|
||||
(str (UUID/randomUUID)))
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-membership!
|
||||
"Verify user is a member of the community. Returns the membership row."
|
||||
[ds community-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this community"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-role!
|
||||
"Verify user has the required role or higher. Returns membership row."
|
||||
[ds community-id user-id required-role]
|
||||
(let [member (check-membership! ds community-id user-id)
|
||||
role (:role member)
|
||||
hierarchy {"owner" 3 "admin" 2 "member" 1}
|
||||
has-level (get hierarchy role 0)
|
||||
need-level (get hierarchy required-role 0)]
|
||||
(when (< has-level need-level)
|
||||
(throw (ex-info (str "Requires " required-role " role or higher")
|
||||
{:type :ajet.chat/forbidden})))
|
||||
member))
|
||||
|
||||
(defn- publish-event! [nats subject event-type payload]
|
||||
(try
|
||||
(eventbus/publish! nats subject event-type payload)
|
||||
(catch Exception e
|
||||
(log/error e "Failed to publish event" event-type "to" subject))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn create-community
|
||||
"POST /api/communities
|
||||
Creates community, adds user as owner, creates #general channel."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
params (:body-params request)
|
||||
name-v (:name params)
|
||||
slug (:slug params)]
|
||||
|
||||
;; Validate required fields
|
||||
(when (or (str/blank? name-v) (str/blank? slug))
|
||||
(throw (ex-info "Name and slug are required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Validate slug format
|
||||
(when-not (re-matches #"[a-z0-9][a-z0-9-]*[a-z0-9]" slug)
|
||||
(throw (ex-info "Slug must be lowercase letters, digits, and hyphens"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Check slug uniqueness
|
||||
(when (db/execute-one! ds
|
||||
{:select [:id]
|
||||
:from [:communities]
|
||||
:where [:= :slug slug]})
|
||||
(throw (ex-info "A community with this slug already exists"
|
||||
{:type :ajet.chat/conflict})))
|
||||
|
||||
(let [community-id (uuid)
|
||||
channel-id (uuid)]
|
||||
(db/with-transaction [tx ds]
|
||||
;; Create community
|
||||
(db/execute! tx
|
||||
{:insert-into :communities
|
||||
:values [{:id [:cast community-id :uuid]
|
||||
:name name-v
|
||||
:slug slug}]})
|
||||
|
||||
;; Add creator as owner
|
||||
(db/execute! tx
|
||||
{:insert-into :community-members
|
||||
:values [{:community-id [:cast community-id :uuid]
|
||||
:user-id [:cast user-id :uuid]
|
||||
:role "owner"}]})
|
||||
|
||||
;; Create #general channel
|
||||
(db/execute! tx
|
||||
{:insert-into :channels
|
||||
:values [{:id [:cast channel-id :uuid]
|
||||
:community-id [:cast community-id :uuid]
|
||||
:name "general"
|
||||
:type "text"
|
||||
:visibility "public"}]})
|
||||
|
||||
;; Add creator to #general
|
||||
(db/execute! tx
|
||||
{:insert-into :channel-members
|
||||
:values [{:channel-id [:cast channel-id :uuid]
|
||||
:user-id [:cast user-id :uuid]}]}))
|
||||
|
||||
;; Fetch the created community
|
||||
(let [community (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:communities]
|
||||
:where [:= :id [:cast community-id :uuid]]})]
|
||||
|
||||
;; Publish event
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:community/created
|
||||
{:community-id community-id
|
||||
:name name-v
|
||||
:slug slug
|
||||
:created-by user-id})
|
||||
|
||||
(mw/json-response 201 community)))))
|
||||
|
||||
(defn list-communities
|
||||
"GET /api/communities
|
||||
Returns communities the authenticated user belongs to."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
communities (db/execute! ds
|
||||
{:select [:c.*]
|
||||
:from [[:communities :c]]
|
||||
:join [[:community-members :cm]
|
||||
[:= :c.id :cm.community-id]]
|
||||
:where [:= :cm.user-id [:cast user-id :uuid]]
|
||||
:order-by [[:c.name :asc]]})]
|
||||
(mw/json-response communities)))
|
||||
|
||||
(defn get-community
|
||||
"GET /api/communities/:id
|
||||
Returns community details. Requires membership."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :id])
|
||||
_ (check-membership! ds community-id user-id)
|
||||
community (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:communities]
|
||||
:where [:= :id [:cast community-id :uuid]]})]
|
||||
(if community
|
||||
(mw/json-response community)
|
||||
(mw/error-response 404 "NOT_FOUND" "Community not found"))))
|
||||
|
||||
(defn update-community
|
||||
"PUT /api/communities/:id
|
||||
Updates community name/slug. Owner only."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
community-id (get-in request [:path-params :id])
|
||||
params (:body-params request)]
|
||||
|
||||
(check-role! ds community-id user-id "owner")
|
||||
|
||||
;; Validate slug format if provided
|
||||
(when (and (:slug params) (not (re-matches #"[a-z0-9][a-z0-9-]*[a-z0-9]" (:slug params))))
|
||||
(throw (ex-info "Slug must be lowercase letters, digits, and hyphens"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Check slug uniqueness if changing
|
||||
(when (:slug params)
|
||||
(let [existing (db/execute-one! ds
|
||||
{:select [:id]
|
||||
:from [:communities]
|
||||
:where [:and
|
||||
[:= :slug (:slug params)]
|
||||
[:!= :id [:cast community-id :uuid]]]})]
|
||||
(when existing
|
||||
(throw (ex-info "A community with this slug already exists"
|
||||
{:type :ajet.chat/conflict})))))
|
||||
|
||||
(let [updates (cond-> {}
|
||||
(:name params) (assoc :name (:name params))
|
||||
(:slug params) (assoc :slug (:slug params)))]
|
||||
(when (seq updates)
|
||||
(db/execute! ds
|
||||
{:update :communities
|
||||
:set updates
|
||||
:where [:= :id [:cast community-id :uuid]]}))
|
||||
|
||||
(let [community (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:communities]
|
||||
:where [:= :id [:cast community-id :uuid]]})]
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:community/updated
|
||||
{:community-id community-id
|
||||
:updated-by user-id
|
||||
:changes updates})
|
||||
|
||||
(mw/json-response community)))))
|
||||
|
||||
(defn delete-community
|
||||
"DELETE /api/communities/:id
|
||||
Deletes community and all associated data. Owner only."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
community-id (get-in request [:path-params :id])]
|
||||
|
||||
(check-role! ds community-id user-id "owner")
|
||||
|
||||
;; CASCADE handles cleanup of members, channels, messages, etc.
|
||||
(db/execute! ds
|
||||
{:delete-from :communities
|
||||
:where [:= :id [:cast community-id :uuid]]})
|
||||
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:community/deleted
|
||||
{:community-id community-id
|
||||
:deleted-by user-id})
|
||||
|
||||
(mw/json-response 204 nil)))
|
||||
@@ -0,0 +1,169 @@
|
||||
(ns ajet.chat.api.handlers.dms
|
||||
"DM (Direct Message) handlers.
|
||||
|
||||
DMs are channels with type 'dm' or 'group_dm' and a NULL community_id.
|
||||
Creating a 1:1 DM is idempotent - returns existing if one exists
|
||||
between the two users."
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- uuid [] (str (UUID/randomUUID)))
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- channel-with-members
|
||||
"Fetch a DM channel with its members."
|
||||
[ds channel-id]
|
||||
(let [channel (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channels]
|
||||
:where [:= :id [:cast channel-id :uuid]]})
|
||||
members (db/execute! ds
|
||||
{:select [:u.id :u.username :u.display-name :u.avatar-url]
|
||||
:from [[:channel-members :cm]]
|
||||
:join [[:users :u] [:= :u.id :cm.user-id]]
|
||||
:where [:= :cm.channel-id [:cast channel-id :uuid]]})]
|
||||
(assoc channel :members members)))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn list-dms
|
||||
"GET /api/dms
|
||||
Lists all DM/group_dm channels the user is a member of.
|
||||
Ordered by most recent message activity."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
;; Get DM channels the user is in, ordered by latest message
|
||||
channels (db/execute! ds
|
||||
{:select [:c.*]
|
||||
:from [[:channels :c]]
|
||||
:join [[:channel-members :cm]
|
||||
[:= :cm.channel-id :c.id]]
|
||||
:where [:and
|
||||
[:= :cm.user-id [:cast user-id :uuid]]
|
||||
[:in :c.type ["dm" "group_dm"]]]
|
||||
:order-by [[:c.created-at :desc]]})
|
||||
;; Enrich with member info
|
||||
result (mapv (fn [ch]
|
||||
(let [members (db/execute! ds
|
||||
{:select [:u.id :u.username :u.display-name :u.avatar-url]
|
||||
:from [[:channel-members :chm]]
|
||||
:join [[:users :u] [:= :u.id :chm.user-id]]
|
||||
:where [:= :chm.channel-id (:id ch)]})]
|
||||
(assoc ch :members members)))
|
||||
channels)]
|
||||
(mw/json-response result)))
|
||||
|
||||
(defn create-dm
|
||||
"POST /api/dms
|
||||
Creates a 1:1 DM channel, or returns existing one if it already exists."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
params (:body-params request)
|
||||
target-id (:user_id params)]
|
||||
|
||||
(when (str/blank? target-id)
|
||||
(throw (ex-info "user_id is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
(when (= user-id target-id)
|
||||
(throw (ex-info "Cannot create a DM with yourself"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Check target user exists
|
||||
(when-not (db/execute-one! ds
|
||||
{:select [:id]
|
||||
:from [:users]
|
||||
:where [:= :id [:cast target-id :uuid]]})
|
||||
(throw (ex-info "Target user not found"
|
||||
{:type :ajet.chat/not-found})))
|
||||
|
||||
;; Check for existing DM between these two users
|
||||
(let [existing (db/execute-one! ds
|
||||
{:select [:c.id]
|
||||
:from [[:channels :c]]
|
||||
:join [[:channel-members :cm1]
|
||||
[:= :cm1.channel-id :c.id]
|
||||
[:channel-members :cm2]
|
||||
[:= :cm2.channel-id :c.id]]
|
||||
:where [:and
|
||||
[:= :c.type "dm"]
|
||||
[:= :cm1.user-id [:cast user-id :uuid]]
|
||||
[:= :cm2.user-id [:cast target-id :uuid]]]})]
|
||||
(if existing
|
||||
;; Return existing DM
|
||||
(mw/json-response (channel-with-members ds (str (:id existing))))
|
||||
|
||||
;; Create new DM
|
||||
(let [channel-id (uuid)]
|
||||
(db/with-transaction [tx ds]
|
||||
(db/execute! tx
|
||||
{:insert-into :channels
|
||||
:values [{:id [:cast channel-id :uuid]
|
||||
:name "dm"
|
||||
:type "dm"
|
||||
:visibility "private"}]})
|
||||
(db/execute! tx
|
||||
{:insert-into :channel-members
|
||||
:values [{:channel-id [:cast channel-id :uuid]
|
||||
:user-id [:cast user-id :uuid]}
|
||||
{:channel-id [:cast channel-id :uuid]
|
||||
:user-id [:cast target-id :uuid]}]}))
|
||||
|
||||
(mw/json-response 201 (channel-with-members ds channel-id)))))))
|
||||
|
||||
(defn create-group-dm
|
||||
"POST /api/dms/group
|
||||
Creates a group DM with multiple users."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
params (:body-params request)
|
||||
user-ids (:user_ids params)]
|
||||
|
||||
(when (or (nil? user-ids) (< (count user-ids) 2))
|
||||
(throw (ex-info "At least 2 other user IDs are required for a group DM"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Ensure all members include the creator
|
||||
(let [all-member-ids (distinct (cons user-id user-ids))
|
||||
channel-id (uuid)]
|
||||
|
||||
;; Verify all users exist
|
||||
(doseq [uid user-ids]
|
||||
(when-not (db/execute-one! ds
|
||||
{:select [:id]
|
||||
:from [:users]
|
||||
:where [:= :id [:cast uid :uuid]]})
|
||||
(throw (ex-info (str "User not found: " uid)
|
||||
{:type :ajet.chat/not-found}))))
|
||||
|
||||
(db/with-transaction [tx ds]
|
||||
(db/execute! tx
|
||||
{:insert-into :channels
|
||||
:values [{:id [:cast channel-id :uuid]
|
||||
:name "group-dm"
|
||||
:type "group_dm"
|
||||
:visibility "private"}]})
|
||||
|
||||
(db/execute! tx
|
||||
{:insert-into :channel-members
|
||||
:values (mapv (fn [uid]
|
||||
{:channel-id [:cast channel-id :uuid]
|
||||
:user-id [:cast uid :uuid]})
|
||||
all-member-ids)}))
|
||||
|
||||
(mw/json-response 201 (channel-with-members ds channel-id)))))
|
||||
@@ -0,0 +1,73 @@
|
||||
(ns ajet.chat.api.handlers.health
|
||||
"Health check handler.
|
||||
|
||||
Checks connectivity to DB, NATS, and MinIO. Returns 200 if all healthy,
|
||||
503 if any check fails (degraded mode)."
|
||||
(:require [clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [software.amazon.awssdk.services.s3 S3Client]
|
||||
[software.amazon.awssdk.services.s3.model HeadBucketRequest]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Health Checks
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- check-db
|
||||
"Test database connectivity with a simple query."
|
||||
[ds]
|
||||
(try
|
||||
(db/execute-sql! ds ["SELECT 1"])
|
||||
"ok"
|
||||
(catch Exception e
|
||||
(log/error e "DB health check failed")
|
||||
"error")))
|
||||
|
||||
(defn- check-nats
|
||||
"Test NATS connection status."
|
||||
[nats]
|
||||
(try
|
||||
(if (and nats (eventbus/connected? nats))
|
||||
"ok"
|
||||
"error")
|
||||
(catch Exception e
|
||||
(log/error e "NATS health check failed")
|
||||
"error")))
|
||||
|
||||
(defn- check-minio
|
||||
"Test MinIO/S3 connectivity by checking if the bucket exists."
|
||||
[^S3Client s3 bucket]
|
||||
(try
|
||||
(.headBucket s3 (-> (HeadBucketRequest/builder)
|
||||
(.bucket bucket)
|
||||
(.build)))
|
||||
"ok"
|
||||
(catch Exception e
|
||||
(log/error e "MinIO health check failed")
|
||||
"error")))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handler
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn health-check
|
||||
"GET /api/health
|
||||
Returns health status for all dependencies.
|
||||
200 = all ok, 503 = at least one check failed."
|
||||
[request]
|
||||
(let [ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
s3 (get-in request [:system :s3])
|
||||
bucket (get-in request [:system :bucket])
|
||||
|
||||
db-status (check-db ds)
|
||||
nats-status (check-nats nats)
|
||||
minio-status (check-minio s3 bucket)
|
||||
|
||||
checks {:db db-status :nats nats-status :minio minio-status}
|
||||
all-ok (every? #(= "ok" %) (vals checks))
|
||||
status (if all-ok "ok" "degraded")
|
||||
code (if all-ok 200 503)]
|
||||
|
||||
(mw/json-response code {:status status :checks checks})))
|
||||
@@ -0,0 +1,297 @@
|
||||
(ns ajet.chat.api.handlers.invites
|
||||
"Invite management handlers.
|
||||
|
||||
Invites are links with unique codes that allow users to join a community.
|
||||
They can have optional max_uses and expiry. Direct invites create a
|
||||
notification for the target user."
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]
|
||||
[java.security SecureRandom]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- uuid [] (str (UUID/randomUUID)))
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-membership! [ds community-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this community"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-role! [ds community-id user-id required-role]
|
||||
(let [member (check-membership! ds community-id user-id)
|
||||
role (:role member)
|
||||
hierarchy {"owner" 3 "admin" 2 "member" 1}
|
||||
has-level (get hierarchy role 0)
|
||||
need-level (get hierarchy required-role 0)]
|
||||
(when (< has-level need-level)
|
||||
(throw (ex-info (str "Requires " required-role " role or higher")
|
||||
{:type :ajet.chat/forbidden})))
|
||||
member))
|
||||
|
||||
(defn- generate-invite-code
|
||||
"Generate a random alphanumeric invite code."
|
||||
[]
|
||||
(let [chars "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
sr (SecureRandom.)
|
||||
sb (StringBuilder. 8)]
|
||||
(dotimes [_ 8]
|
||||
(.append sb (.charAt chars (.nextInt sr (count chars)))))
|
||||
(.toString sb)))
|
||||
|
||||
(defn- publish-event! [nats subject event-type payload]
|
||||
(try
|
||||
(eventbus/publish! nats subject event-type payload)
|
||||
(catch Exception e
|
||||
(log/error e "Failed to publish event" event-type "to" subject))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn create-invite
|
||||
"POST /api/communities/:cid/invites
|
||||
Creates an invite link. Admin+ only."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
params (:body-params request)
|
||||
max-uses (:max_uses params)
|
||||
expires-hrs (:expires_in_hours params)]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
(let [invite-id (uuid)
|
||||
code (generate-invite-code)
|
||||
values (cond-> {:id [:cast invite-id :uuid]
|
||||
:community-id [:cast community-id :uuid]
|
||||
:created-by [:cast user-id :uuid]
|
||||
:code code}
|
||||
max-uses (assoc :max-uses max-uses)
|
||||
expires-hrs (assoc :expires-at
|
||||
[:raw (str "now() + interval '" expires-hrs " hours'")]))]
|
||||
|
||||
(db/execute! ds
|
||||
{:insert-into :invites
|
||||
:values [values]})
|
||||
|
||||
(let [invite (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:invites]
|
||||
:where [:= :id [:cast invite-id :uuid]]})]
|
||||
(mw/json-response 201 (assoc invite :url (str "/invite/" code)))))))
|
||||
|
||||
(defn list-invites
|
||||
"GET /api/communities/:cid/invites
|
||||
Lists active (non-expired, non-exhausted) invites. Admin+ only."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :cid])]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
(let [invites (db/execute! ds
|
||||
{:select [:*]
|
||||
:from [:invites]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:or
|
||||
[:= :expires-at nil]
|
||||
[:> :expires-at [:now]]]
|
||||
[:or
|
||||
[:= :max-uses nil]
|
||||
[:< :uses :max-uses]]]
|
||||
:order-by [[:created-at :desc]]})]
|
||||
(mw/json-response invites))))
|
||||
|
||||
(defn revoke-invite
|
||||
"DELETE /api/invites/:id
|
||||
Deletes an invite. Admin+ of the invite's community."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
invite-id (get-in request [:path-params :id])
|
||||
invite (or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:invites]
|
||||
:where [:= :id [:cast invite-id :uuid]]})
|
||||
(throw (ex-info "Invite not found"
|
||||
{:type :ajet.chat/not-found})))]
|
||||
|
||||
(check-role! ds (str (:community-id invite)) user-id "admin")
|
||||
|
||||
(db/execute! ds
|
||||
{:delete-from :invites
|
||||
:where [:= :id [:cast invite-id :uuid]]})
|
||||
|
||||
(mw/json-response 204 nil)))
|
||||
|
||||
(defn accept-invite
|
||||
"POST /api/invites/:code/accept
|
||||
Accepts an invite and joins the community. Idempotent if already a member."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
code (get-in request [:path-params :code])
|
||||
invite (or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:invites]
|
||||
:where [:= :code code]})
|
||||
(throw (ex-info "Invite not found"
|
||||
{:type :ajet.chat/not-found})))
|
||||
community-id (str (:community-id invite))]
|
||||
|
||||
;; Check expiry
|
||||
(when (:expires-at invite)
|
||||
(let [expires-at (:expires-at invite)
|
||||
now (java.time.Instant/now)
|
||||
exp-inst (if (instance? java.time.Instant expires-at)
|
||||
expires-at
|
||||
(.toInstant expires-at))]
|
||||
(when (.isAfter now exp-inst)
|
||||
(throw (ex-info "This invite has expired"
|
||||
{:type :ajet.chat/gone})))))
|
||||
|
||||
;; Check max uses
|
||||
(when (and (:max-uses invite) (>= (:uses invite) (:max-uses invite)))
|
||||
(throw (ex-info "This invite has reached its maximum number of uses"
|
||||
{:type :ajet.chat/gone})))
|
||||
|
||||
;; Check if user is banned
|
||||
(when (db/execute-one! ds
|
||||
{:select [[:1 :exists]]
|
||||
:from [:bans]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "You are banned from this community"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
|
||||
;; Check if already a member (idempotent)
|
||||
(let [already-member (db/execute-one! ds
|
||||
{:select [:user-id]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})]
|
||||
(if already-member
|
||||
(mw/json-response {:status "already_member" :community-id community-id})
|
||||
|
||||
(do
|
||||
(db/with-transaction [tx ds]
|
||||
;; Add as member
|
||||
(db/execute! tx
|
||||
{:insert-into :community-members
|
||||
:values [{:community-id [:cast community-id :uuid]
|
||||
:user-id [:cast user-id :uuid]
|
||||
:role "member"}]})
|
||||
|
||||
;; Join #general channel
|
||||
(when-let [general (db/execute-one! tx
|
||||
{:select [:id]
|
||||
:from [:channels]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :name "general"]]})]
|
||||
(db/execute! tx
|
||||
{:insert-into :channel-members
|
||||
:values [{:channel-id (:id general)
|
||||
:user-id [:cast user-id :uuid]}]
|
||||
:on-conflict [:channel-id :user-id]
|
||||
:do-nothing true}))
|
||||
|
||||
;; Increment invite uses
|
||||
(db/execute! tx
|
||||
{:update :invites
|
||||
:set {:uses [:+ :uses 1]}
|
||||
:where [:= :id (:id invite)]}))
|
||||
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:member/joined
|
||||
{:community-id community-id
|
||||
:user-id user-id
|
||||
:invite-code code})
|
||||
|
||||
(mw/json-response {:status "joined" :community-id community-id}))))))
|
||||
|
||||
(defn direct-invite
|
||||
"POST /api/communities/:cid/invites/direct
|
||||
Creates a direct invite for a specific user by ID. Admin+ only.
|
||||
Creates an invite + a notification for the target user."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
params (:body-params request)
|
||||
target-uid (:user_id params)]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
(when (str/blank? target-uid)
|
||||
(throw (ex-info "user_id is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Verify target user exists
|
||||
(when-not (db/execute-one! ds
|
||||
{:select [:id]
|
||||
:from [:users]
|
||||
:where [:= :id [:cast target-uid :uuid]]})
|
||||
(throw (ex-info "Target user not found"
|
||||
{:type :ajet.chat/not-found})))
|
||||
|
||||
(let [invite-id (uuid)
|
||||
code (generate-invite-code)
|
||||
notif-id (uuid)]
|
||||
|
||||
(db/with-transaction [tx ds]
|
||||
;; Create invite (single use)
|
||||
(db/execute! tx
|
||||
{:insert-into :invites
|
||||
:values [{:id [:cast invite-id :uuid]
|
||||
:community-id [:cast community-id :uuid]
|
||||
:created-by [:cast user-id :uuid]
|
||||
:code code
|
||||
:max-uses 1}]})
|
||||
|
||||
;; Create notification for target user
|
||||
(db/execute! tx
|
||||
{:insert-into :notifications
|
||||
:values [{:id [:cast notif-id :uuid]
|
||||
:user-id [:cast target-uid :uuid]
|
||||
:type "invite"
|
||||
:source-id [:cast invite-id :uuid]}]}))
|
||||
|
||||
;; Publish notification event
|
||||
(publish-event! nats
|
||||
(str "chat.notifications." target-uid)
|
||||
:notification/new
|
||||
{:user-id target-uid
|
||||
:type "invite"
|
||||
:invite-code code
|
||||
:community-id community-id
|
||||
:invited-by user-id})
|
||||
|
||||
(let [invite (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:invites]
|
||||
:where [:= :id [:cast invite-id :uuid]]})]
|
||||
(mw/json-response 201 (assoc invite :url (str "/invite/" code)))))))
|
||||
@@ -0,0 +1,395 @@
|
||||
(ns ajet.chat.api.handlers.messages
|
||||
"Message handlers: send, list, get, edit, delete, thread, and read-tracking.
|
||||
|
||||
Messages use cursor-based pagination. The send handler parses mentions,
|
||||
creates mention records, creates notifications for @mentioned users,
|
||||
and publishes events to the correct NATS subject."
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.shared.mentions :as mentions]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]
|
||||
[java.time Instant Duration]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- uuid [] (str (UUID/randomUUID)))
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-channel-member! [ds channel-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channel-members]
|
||||
:where [:and
|
||||
[:= :channel-id [:cast channel-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this channel"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- get-channel-row [ds channel-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channels]
|
||||
:where [:= :id [:cast channel-id :uuid]]})
|
||||
(throw (ex-info "Channel not found" {:type :ajet.chat/not-found}))))
|
||||
|
||||
(defn- get-message-row [ds message-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:messages]
|
||||
:where [:= :id [:cast message-id :uuid]]})
|
||||
(throw (ex-info "Message not found" {:type :ajet.chat/not-found}))))
|
||||
|
||||
(defn- nats-subject-for-channel
|
||||
"Determine the NATS subject based on channel type.
|
||||
Community channels -> chat.events.{community-id}
|
||||
DM channels -> chat.dm.{channel-id}"
|
||||
[channel]
|
||||
(if (:community-id channel)
|
||||
(str "chat.events." (:community-id channel))
|
||||
(str "chat.dm." (:id channel))))
|
||||
|
||||
(defn- publish-event! [nats subject event-type payload]
|
||||
(try
|
||||
(eventbus/publish! nats subject event-type payload)
|
||||
(catch Exception e
|
||||
(log/error e "Failed to publish event" event-type "to" subject))))
|
||||
|
||||
(defn- create-mention-records!
|
||||
"Parse mentions from message body, create mention rows, and create notifications."
|
||||
[ds message-id body-md channel-id user-id]
|
||||
(let [parsed (mentions/parse body-md)]
|
||||
(doseq [{:keys [type id]} parsed]
|
||||
(let [mention-id (uuid)]
|
||||
(db/execute! ds
|
||||
{:insert-into :mentions
|
||||
:values [(cond-> {:id [:cast mention-id :uuid]
|
||||
:message-id [:cast message-id :uuid]
|
||||
:target-type (name type)}
|
||||
id (assoc :target-id [:cast id :uuid]))]}))
|
||||
|
||||
;; Create notifications for user mentions
|
||||
(when (and (= type :user) id)
|
||||
(let [notif-id (uuid)]
|
||||
(db/execute! ds
|
||||
{:insert-into :notifications
|
||||
:values [{:id [:cast notif-id :uuid]
|
||||
:user-id [:cast id :uuid]
|
||||
:type "mention"
|
||||
:source-id [:cast message-id :uuid]}]})))
|
||||
|
||||
;; For @here mentions, notify all channel members (except sender)
|
||||
(when (= type :here)
|
||||
(let [members (db/execute! ds
|
||||
{:select [:user-id]
|
||||
:from [:channel-members]
|
||||
:where [:and
|
||||
[:= :channel-id [:cast channel-id :uuid]]
|
||||
[:!= :user-id [:cast user-id :uuid]]]})]
|
||||
(doseq [member members]
|
||||
(let [notif-id (uuid)]
|
||||
(db/execute! ds
|
||||
{:insert-into :notifications
|
||||
:values [{:id [:cast notif-id :uuid]
|
||||
:user-id (:user-id member)
|
||||
:type "mention"
|
||||
:source-id [:cast message-id :uuid]}]}))))))))
|
||||
|
||||
(defn- create-thread-notifications!
|
||||
"Notify thread participants (except the sender) when a thread reply is created."
|
||||
[ds message-id parent-id user-id]
|
||||
(let [participants (db/execute! ds
|
||||
{:select-distinct [:user-id]
|
||||
:from [:messages]
|
||||
:where [:and
|
||||
[:or
|
||||
[:= :id [:cast parent-id :uuid]]
|
||||
[:= :parent-id [:cast parent-id :uuid]]]
|
||||
[:!= :user-id [:cast user-id :uuid]]]})]
|
||||
(doseq [participant participants]
|
||||
(let [notif-id (uuid)]
|
||||
(db/execute! ds
|
||||
{:insert-into :notifications
|
||||
:values [{:id [:cast notif-id :uuid]
|
||||
:user-id (:user-id participant)
|
||||
:type "thread_reply"
|
||||
:source-id [:cast message-id :uuid]}]})))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn list-messages
|
||||
"GET /api/channels/:id/messages?before=<uuid>&after=<uuid>&limit=N
|
||||
Cursor-based pagination. Default limit 50, max 100."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
channel-id (get-in request [:path-params :id])
|
||||
params (:query-params request)
|
||||
before (get params "before")
|
||||
after (get params "after")
|
||||
limit (min (or (some-> (get params "limit") parse-long) 50) 100)]
|
||||
|
||||
(check-channel-member! ds channel-id user-id)
|
||||
|
||||
(let [base-where [:= :channel-id [:cast channel-id :uuid]]
|
||||
;; Cursor-based pagination using subquery on created_at
|
||||
where-clause (cond
|
||||
before
|
||||
[:and base-where
|
||||
[:< :created-at
|
||||
{:select [:created-at]
|
||||
:from [:messages]
|
||||
:where [:= :id [:cast before :uuid]]}]]
|
||||
|
||||
after
|
||||
[:and base-where
|
||||
[:> :created-at
|
||||
{:select [:created-at]
|
||||
:from [:messages]
|
||||
:where [:= :id [:cast after :uuid]]}]]
|
||||
|
||||
:else base-where)
|
||||
order-dir (if after :asc :desc)
|
||||
messages (db/execute! ds
|
||||
{:select [:*]
|
||||
:from [:messages]
|
||||
:where where-clause
|
||||
:order-by [[:created-at order-dir]]
|
||||
:limit limit})
|
||||
;; Always return newest-last ordering
|
||||
messages (if (= order-dir :desc)
|
||||
(vec (reverse messages))
|
||||
messages)]
|
||||
(mw/json-response messages))))
|
||||
|
||||
(defn send-message
|
||||
"POST /api/channels/:id/messages
|
||||
Creates message, parses mentions, creates notifications, publishes event."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
channel-id (get-in request [:path-params :id])
|
||||
params (:body-params request)
|
||||
body-md (:body_md params)
|
||||
parent-id (:parent_id params)]
|
||||
|
||||
(check-channel-member! ds channel-id user-id)
|
||||
|
||||
(when (str/blank? body-md)
|
||||
(throw (ex-info "Message body is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; If reply, resolve parent to root (no nested threads)
|
||||
(let [resolved-parent-id
|
||||
(when parent-id
|
||||
(let [parent (get-message-row ds parent-id)]
|
||||
(or (:parent-id parent) (str (:id parent)))))
|
||||
message-id (uuid)]
|
||||
|
||||
(db/with-transaction [tx ds]
|
||||
(db/execute! tx
|
||||
{:insert-into :messages
|
||||
:values [(cond-> {:id [:cast message-id :uuid]
|
||||
:channel-id [:cast channel-id :uuid]
|
||||
:user-id [:cast user-id :uuid]
|
||||
:body-md body-md}
|
||||
resolved-parent-id
|
||||
(assoc :parent-id [:cast resolved-parent-id :uuid]))]})
|
||||
|
||||
;; Create mention records and notifications
|
||||
(create-mention-records! tx message-id body-md channel-id user-id)
|
||||
|
||||
;; Create thread notifications if this is a reply
|
||||
(when resolved-parent-id
|
||||
(create-thread-notifications! tx message-id resolved-parent-id user-id)))
|
||||
|
||||
;; Publish event
|
||||
(let [channel (get-channel-row ds channel-id)
|
||||
subject (nats-subject-for-channel channel)
|
||||
message (get-message-row ds message-id)]
|
||||
|
||||
(publish-event! nats subject :message/created
|
||||
{:message-id message-id
|
||||
:channel-id channel-id
|
||||
:user-id user-id
|
||||
:body-md body-md
|
||||
:parent-id resolved-parent-id
|
||||
:community-id (when (:community-id channel)
|
||||
(str (:community-id channel)))})
|
||||
|
||||
(mw/json-response 201 message)))))
|
||||
|
||||
(defn get-message
|
||||
"GET /api/messages/:id
|
||||
Returns a single message. Requires channel membership."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
message-id (get-in request [:path-params :id])
|
||||
message (get-message-row ds message-id)]
|
||||
|
||||
(check-channel-member! ds (str (:channel-id message)) user-id)
|
||||
(mw/json-response message)))
|
||||
|
||||
(defn edit-message
|
||||
"PUT /api/messages/:id
|
||||
Edits message body. Author only, within 1-hour window.
|
||||
Re-parses mentions and publishes :message/edited."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
message-id (get-in request [:path-params :id])
|
||||
params (:body-params request)
|
||||
new-body (:body_md params)
|
||||
message (get-message-row ds message-id)]
|
||||
|
||||
;; Author check
|
||||
(when (not= (str (:user-id message)) user-id)
|
||||
(throw (ex-info "Only the author can edit this message"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
|
||||
;; 1-hour edit window
|
||||
(let [created-at (:created-at message)
|
||||
created-inst (if (instance? java.time.Instant created-at)
|
||||
created-at
|
||||
(.toInstant created-at))
|
||||
one-hour-later (.plus created-inst (Duration/ofMinutes 60))
|
||||
now (Instant/now)]
|
||||
(when (.isAfter now one-hour-later)
|
||||
(throw (ex-info "Edit window has expired (1 hour)"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(when (str/blank? new-body)
|
||||
(throw (ex-info "Message body is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
(db/with-transaction [tx ds]
|
||||
;; Update message
|
||||
(db/execute! tx
|
||||
{:update :messages
|
||||
:set {:body-md new-body
|
||||
:edited-at [:now]}
|
||||
:where [:= :id [:cast message-id :uuid]]})
|
||||
|
||||
;; Delete old mentions and re-create
|
||||
(db/execute! tx
|
||||
{:delete-from :mentions
|
||||
:where [:= :message-id [:cast message-id :uuid]]})
|
||||
|
||||
(create-mention-records! tx message-id new-body
|
||||
(str (:channel-id message)) user-id))
|
||||
|
||||
(let [updated (get-message-row ds message-id)
|
||||
channel (get-channel-row ds (str (:channel-id message)))
|
||||
subject (nats-subject-for-channel channel)]
|
||||
|
||||
(publish-event! nats subject :message/edited
|
||||
{:message-id message-id
|
||||
:channel-id (str (:channel-id message))
|
||||
:user-id user-id
|
||||
:body-md new-body
|
||||
:community-id (when (:community-id channel)
|
||||
(str (:community-id channel)))})
|
||||
|
||||
(mw/json-response updated))))
|
||||
|
||||
(defn delete-message
|
||||
"DELETE /api/messages/:id
|
||||
Deletes message. Author or admin+ of the community."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
message-id (get-in request [:path-params :id])
|
||||
message (get-message-row ds message-id)
|
||||
channel (get-channel-row ds (str (:channel-id message)))
|
||||
is-author (= (str (:user-id message)) user-id)]
|
||||
|
||||
;; Check permission: author or admin+ in the community
|
||||
(when-not is-author
|
||||
(if (:community-id channel)
|
||||
(let [member (db/execute-one! ds
|
||||
{:select [:role]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id (:community-id channel)]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
role (:role member)
|
||||
hierarchy {"owner" 3 "admin" 2 "member" 1}]
|
||||
(when (< (get hierarchy role 0) (get hierarchy "admin" 0))
|
||||
(throw (ex-info "Only the author or an admin can delete this message"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
;; For DMs, only the author can delete
|
||||
(throw (ex-info "Only the author can delete this message"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
;; CASCADE handles mentions, reactions, attachments
|
||||
(db/execute! ds
|
||||
{:delete-from :messages
|
||||
:where [:= :id [:cast message-id :uuid]]})
|
||||
|
||||
(let [subject (nats-subject-for-channel channel)]
|
||||
(publish-event! nats subject :message/deleted
|
||||
{:message-id message-id
|
||||
:channel-id (str (:channel-id message))
|
||||
:user-id user-id
|
||||
:community-id (when (:community-id channel)
|
||||
(str (:community-id channel)))}))
|
||||
|
||||
(mw/json-response 204 nil)))
|
||||
|
||||
(defn get-thread
|
||||
"GET /api/messages/:id/thread
|
||||
Returns all replies to a message, ordered by created_at."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
message-id (get-in request [:path-params :id])
|
||||
message (get-message-row ds message-id)]
|
||||
|
||||
(check-channel-member! ds (str (:channel-id message)) user-id)
|
||||
|
||||
;; Get root message + all replies
|
||||
(let [replies (db/execute! ds
|
||||
{:select [:*]
|
||||
:from [:messages]
|
||||
:where [:= :parent-id [:cast message-id :uuid]]
|
||||
:order-by [[:created-at :asc]]})]
|
||||
(mw/json-response {:root message
|
||||
:replies replies}))))
|
||||
|
||||
(defn mark-read
|
||||
"POST /api/channels/:id/read
|
||||
Updates last_read_message_id for the user in this channel."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
channel-id (get-in request [:path-params :id])
|
||||
params (:body-params request)
|
||||
msg-id (:last_read_message_id params)]
|
||||
|
||||
(check-channel-member! ds channel-id user-id)
|
||||
|
||||
(when (str/blank? msg-id)
|
||||
(throw (ex-info "last_read_message_id is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
(db/execute! ds
|
||||
{:update :channel-members
|
||||
:set {:last-read-message-id [:cast msg-id :uuid]}
|
||||
:where [:and
|
||||
[:= :channel-id [:cast channel-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
|
||||
(mw/json-response {:status "ok"})))
|
||||
@@ -0,0 +1,111 @@
|
||||
(ns ajet.chat.api.handlers.notifications
|
||||
"Notification handlers: list, mark read, unread count.
|
||||
|
||||
Notifications are created by message handlers (mentions, DMs, thread replies)
|
||||
and invite handlers. This namespace handles reading and managing them."
|
||||
(:require [ajet.chat.shared.db :as db]
|
||||
[ajet.chat.api.middleware :as mw]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn list-notifications
|
||||
"GET /api/notifications?after=<uuid>&limit=N&unread=true
|
||||
Returns notifications for the authenticated user, newest first."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
params (:query-params request)
|
||||
after (get params "after")
|
||||
limit (min (or (some-> (get params "limit") parse-long) 50) 100)
|
||||
unread (= "true" (get params "unread"))
|
||||
|
||||
base-where [:= :user-id [:cast user-id :uuid]]
|
||||
where-clause (cond-> base-where
|
||||
unread
|
||||
(vector :and [:= :read false])
|
||||
|
||||
after
|
||||
(vector :and
|
||||
[:< :created-at
|
||||
{:select [:created-at]
|
||||
:from [:notifications]
|
||||
:where [:= :id [:cast after :uuid]]}]))
|
||||
|
||||
;; Build the full where clause properly
|
||||
final-where (cond
|
||||
(and unread after)
|
||||
[:and
|
||||
base-where
|
||||
[:= :read false]
|
||||
[:< :created-at
|
||||
{:select [:created-at]
|
||||
:from [:notifications]
|
||||
:where [:= :id [:cast after :uuid]]}]]
|
||||
|
||||
unread
|
||||
[:and base-where [:= :read false]]
|
||||
|
||||
after
|
||||
[:and base-where
|
||||
[:< :created-at
|
||||
{:select [:created-at]
|
||||
:from [:notifications]
|
||||
:where [:= :id [:cast after :uuid]]}]]
|
||||
|
||||
:else base-where)
|
||||
|
||||
notifications (db/execute! ds
|
||||
{:select [:*]
|
||||
:from [:notifications]
|
||||
:where final-where
|
||||
:order-by [[:created-at :desc]]
|
||||
:limit limit})]
|
||||
(mw/json-response notifications)))
|
||||
|
||||
(defn mark-read
|
||||
"POST /api/notifications/read
|
||||
Marks specified notifications as read.
|
||||
Body: {\"notification_ids\": [\"uuid\", ...]}"
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
params (:body-params request)
|
||||
ids (:notification_ids params)]
|
||||
|
||||
(when (or (nil? ids) (empty? ids))
|
||||
(throw (ex-info "notification_ids is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
(let [uuid-ids (mapv (fn [id] [:cast id :uuid]) ids)]
|
||||
(db/execute! ds
|
||||
{:update :notifications
|
||||
:set {:read true}
|
||||
:where [:and
|
||||
[:= :user-id [:cast user-id :uuid]]
|
||||
[:in :id uuid-ids]]}))
|
||||
|
||||
(mw/json-response {:status "ok" :marked-read (count ids)})))
|
||||
|
||||
(defn unread-count
|
||||
"GET /api/notifications/unread-count
|
||||
Returns the count of unread notifications."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
result (db/execute-one! ds
|
||||
{:select [[[:count :*] :count]]
|
||||
:from [:notifications]
|
||||
:where [:and
|
||||
[:= :user-id [:cast user-id :uuid]]
|
||||
[:= :read false]]})]
|
||||
(mw/json-response {:unread-count (:count result 0)})))
|
||||
@@ -0,0 +1,90 @@
|
||||
(ns ajet.chat.api.handlers.presence
|
||||
"Presence and heartbeat handlers.
|
||||
|
||||
Users send heartbeats every 60 seconds. A user is considered online
|
||||
if their last heartbeat was within 2 minutes. Heartbeat responses
|
||||
are immediate; DB update and NATS publishing happen asynchronously."
|
||||
(:require [clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.api.middleware :as mw]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-membership! [ds community-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:user-id]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this community"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn heartbeat
|
||||
"POST /api/heartbeat
|
||||
Reports online status. Responds immediately, updates DB async."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])]
|
||||
|
||||
;; Update last_seen_at asynchronously
|
||||
(future
|
||||
(try
|
||||
(db/execute! ds
|
||||
{:update :users
|
||||
:set {:last-seen-at [:now]}
|
||||
:where [:= :id [:cast user-id :uuid]]})
|
||||
|
||||
;; Publish presence event to all communities the user belongs to
|
||||
(let [communities (db/execute! ds
|
||||
{:select [:community-id]
|
||||
:from [:community-members]
|
||||
:where [:= :user-id [:cast user-id :uuid]]})]
|
||||
(doseq [{:keys [community-id]} communities]
|
||||
(try
|
||||
(eventbus/publish! nats
|
||||
(str "chat.presence." community-id)
|
||||
:presence/online
|
||||
{:user-id user-id
|
||||
:community-id (str community-id)})
|
||||
(catch Exception e
|
||||
(log/error e "Failed to publish presence event")))))
|
||||
(catch Exception e
|
||||
(log/error e "Failed to process heartbeat for user" user-id))))
|
||||
|
||||
;; Respond immediately
|
||||
(mw/json-response {:status "ok"})))
|
||||
|
||||
(defn get-presence
|
||||
"GET /api/communities/:cid/presence
|
||||
Returns list of online members (last heartbeat within 2 minutes)."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
_ (check-membership! ds community-id user-id)
|
||||
|
||||
;; Online = last_seen_at within 2 minutes
|
||||
online-members (db/execute! ds
|
||||
{:select [:u.id :u.username :u.display-name :u.avatar-url
|
||||
:u.status-text :u.last-seen-at]
|
||||
:from [[:community-members :cm]]
|
||||
:join [[:users :u] [:= :u.id :cm.user-id]]
|
||||
:where [:and
|
||||
[:= :cm.community-id [:cast community-id :uuid]]
|
||||
[:!= :u.last-seen-at nil]
|
||||
[:> :u.last-seen-at
|
||||
[:raw "now() - interval '2 minutes'"]]]})]
|
||||
(mw/json-response online-members)))
|
||||
@@ -0,0 +1,152 @@
|
||||
(ns ajet.chat.api.handlers.reactions
|
||||
"Reaction handlers: add, remove, and list reactions on messages.
|
||||
|
||||
Reactions use a composite PK (message_id, user_id, emoji) so each
|
||||
user can only have one reaction per emoji per message."
|
||||
(:require [clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.api.middleware :as mw]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-channel-member! [ds channel-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:user-id]
|
||||
:from [:channel-members]
|
||||
:where [:and
|
||||
[:= :channel-id [:cast channel-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this channel"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- get-message-row [ds message-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:messages]
|
||||
:where [:= :id [:cast message-id :uuid]]})
|
||||
(throw (ex-info "Message not found" {:type :ajet.chat/not-found}))))
|
||||
|
||||
(defn- get-channel-row [ds channel-id]
|
||||
(db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channels]
|
||||
:where [:= :id [:cast channel-id :uuid]]}))
|
||||
|
||||
(defn- publish-event! [nats subject event-type payload]
|
||||
(try
|
||||
(eventbus/publish! nats subject event-type payload)
|
||||
(catch Exception e
|
||||
(log/error e "Failed to publish event" event-type "to" subject))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn add-reaction
|
||||
"PUT /api/messages/:id/reactions/:emoji
|
||||
Adds a reaction. Idempotent (no error if already exists)."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
message-id (get-in request [:path-params :id])
|
||||
emoji (get-in request [:path-params :emoji])
|
||||
message (get-message-row ds message-id)
|
||||
channel-id (str (:channel-id message))]
|
||||
|
||||
(check-channel-member! ds channel-id user-id)
|
||||
|
||||
;; Upsert: insert if not exists (ON CONFLICT DO NOTHING)
|
||||
(db/execute! ds
|
||||
{:insert-into :reactions
|
||||
:values [{:message-id [:cast message-id :uuid]
|
||||
:user-id [:cast user-id :uuid]
|
||||
:emoji emoji}]
|
||||
:on-conflict [:message-id :user-id :emoji]
|
||||
:do-nothing true})
|
||||
|
||||
(let [channel (get-channel-row ds channel-id)
|
||||
subject (if (:community-id channel)
|
||||
(str "chat.events." (:community-id channel))
|
||||
(str "chat.dm." channel-id))]
|
||||
(publish-event! nats subject :reaction/added
|
||||
{:message-id message-id
|
||||
:channel-id channel-id
|
||||
:user-id user-id
|
||||
:emoji emoji}))
|
||||
|
||||
(mw/json-response {:status "ok" :emoji emoji})))
|
||||
|
||||
(defn remove-reaction
|
||||
"DELETE /api/messages/:id/reactions/:emoji
|
||||
Removes a reaction by the current user."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
message-id (get-in request [:path-params :id])
|
||||
emoji (get-in request [:path-params :emoji])
|
||||
message (get-message-row ds message-id)
|
||||
channel-id (str (:channel-id message))]
|
||||
|
||||
(let [deleted (db/execute! ds
|
||||
{:delete-from :reactions
|
||||
:where [:and
|
||||
[:= :message-id [:cast message-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]
|
||||
[:= :emoji emoji]]})]
|
||||
;; Check if anything was deleted
|
||||
(when (zero? (:next.jdbc/update-count (first deleted) 0))
|
||||
(throw (ex-info "Reaction not found"
|
||||
{:type :ajet.chat/not-found}))))
|
||||
|
||||
(let [channel (get-channel-row ds channel-id)
|
||||
subject (if (:community-id channel)
|
||||
(str "chat.events." (:community-id channel))
|
||||
(str "chat.dm." channel-id))]
|
||||
(publish-event! nats subject :reaction/removed
|
||||
{:message-id message-id
|
||||
:channel-id channel-id
|
||||
:user-id user-id
|
||||
:emoji emoji}))
|
||||
|
||||
(mw/json-response 204 nil)))
|
||||
|
||||
(defn list-reactions
|
||||
"GET /api/messages/:id/reactions
|
||||
Returns reactions grouped by emoji with user lists."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
message-id (get-in request [:path-params :id])
|
||||
message (get-message-row ds message-id)]
|
||||
|
||||
(check-channel-member! ds (str (:channel-id message)) user-id)
|
||||
|
||||
(let [reactions (db/execute! ds
|
||||
{:select [:r.emoji :r.user-id :u.username :u.display-name]
|
||||
:from [[:reactions :r]]
|
||||
:join [[:users :u] [:= :u.id :r.user-id]]
|
||||
:where [:= :r.message-id [:cast message-id :uuid]]
|
||||
:order-by [[:r.emoji :asc] [:r.created-at :asc]]})
|
||||
;; Group by emoji
|
||||
grouped (reduce (fn [acc reaction]
|
||||
(update acc (:emoji reaction)
|
||||
(fnil conj [])
|
||||
{:user-id (str (:user-id reaction))
|
||||
:username (:username reaction)
|
||||
:display-name (:display-name reaction)}))
|
||||
{} reactions)
|
||||
result (mapv (fn [[emoji users]]
|
||||
{:emoji emoji
|
||||
:count (count users)
|
||||
:users users})
|
||||
grouped)]
|
||||
(mw/json-response result))))
|
||||
@@ -0,0 +1,132 @@
|
||||
(ns ajet.chat.api.handlers.search
|
||||
"Full-text search handler using PostgreSQL tsvector.
|
||||
|
||||
Supports searching messages, channels, and users with various filters:
|
||||
community, channel, author, date range, and cursor-based pagination."
|
||||
(:require [clojure.string :as str]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.api.middleware :as mw]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- search-messages
|
||||
"Search messages using PostgreSQL full-text search with tsvector.
|
||||
Only returns messages in channels the user has access to."
|
||||
[ds user-id query opts]
|
||||
(let [{:keys [community-id channel-id from after before cursor limit]} opts
|
||||
;; Build SQL with tsvector search
|
||||
base-sql (str
|
||||
"SELECT m.id, m.channel_id, m.user_id, m.body_md, m.created_at, "
|
||||
"u.username, u.display_name, c.name as channel_name "
|
||||
"FROM messages m "
|
||||
"JOIN channels c ON c.id = m.channel_id "
|
||||
"JOIN users u ON u.id = m.user_id "
|
||||
"JOIN channel_members cm ON cm.channel_id = m.channel_id AND cm.user_id = ?::uuid "
|
||||
"WHERE to_tsvector('english', m.body_md) @@ plainto_tsquery('english', ?) ")
|
||||
params (atom [user-id query])
|
||||
clauses (atom [])]
|
||||
|
||||
(when community-id
|
||||
(swap! clauses conj "AND c.community_id = ?::uuid")
|
||||
(swap! params conj community-id))
|
||||
|
||||
(when channel-id
|
||||
(swap! clauses conj "AND m.channel_id = ?::uuid")
|
||||
(swap! params conj channel-id))
|
||||
|
||||
(when from
|
||||
(swap! clauses conj "AND m.user_id = ?::uuid")
|
||||
(swap! params conj from))
|
||||
|
||||
(when after
|
||||
(swap! clauses conj "AND m.created_at > ?::timestamptz")
|
||||
(swap! params conj after))
|
||||
|
||||
(when before
|
||||
(swap! clauses conj "AND m.created_at < ?::timestamptz")
|
||||
(swap! params conj before))
|
||||
|
||||
(when cursor
|
||||
(swap! clauses conj "AND m.created_at < (SELECT created_at FROM messages WHERE id = ?::uuid)")
|
||||
(swap! params conj cursor))
|
||||
|
||||
(let [full-sql (str base-sql
|
||||
(str/join " " @clauses)
|
||||
" ORDER BY m.created_at DESC LIMIT ?")
|
||||
_ (swap! params conj limit)]
|
||||
(db/execute-sql! ds (into [full-sql] @params)))))
|
||||
|
||||
(defn- search-channels
|
||||
"Search channels by name substring within user's communities."
|
||||
[ds user-id query opts]
|
||||
(let [{:keys [community-id limit]} opts
|
||||
base-where [:and
|
||||
[:like [:lower :c.name] [:lower (str "%" query "%")]]
|
||||
[:in :c.community-id
|
||||
{:select [:community-id]
|
||||
:from [:community-members]
|
||||
:where [:= :user-id [:cast user-id :uuid]]}]]
|
||||
where-clause (if community-id
|
||||
[:and base-where
|
||||
[:= :c.community-id [:cast community-id :uuid]]]
|
||||
base-where)]
|
||||
(db/execute! ds
|
||||
{:select [:c.*]
|
||||
:from [[:channels :c]]
|
||||
:where where-clause
|
||||
:order-by [[:c.name :asc]]
|
||||
:limit limit})))
|
||||
|
||||
(defn- search-users
|
||||
"Search users by username or display_name substring."
|
||||
[ds _user-id query opts]
|
||||
(let [{:keys [limit]} opts]
|
||||
(db/execute! ds
|
||||
{:select [:id :username :display-name :avatar-url :status-text]
|
||||
:from [:users]
|
||||
:where [:or
|
||||
[:like [:lower :username] [:lower (str "%" query "%")]]
|
||||
[:like [:lower :display-name] [:lower (str "%" query "%")]]]
|
||||
:order-by [[:username :asc]]
|
||||
:limit limit})))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handler
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn search-handler
|
||||
"GET /api/search?q=term&type=messages|channels|users&community_id=...&channel_id=...&from=...&after=...&before=...&cursor=...&limit=20"
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
params (:query-params request)
|
||||
query (get params "q")
|
||||
type (get params "type")
|
||||
limit (min (or (some-> (get params "limit") parse-long) 20) 100)
|
||||
opts {:community-id (get params "community_id")
|
||||
:channel-id (get params "channel_id")
|
||||
:from (get params "from")
|
||||
:after (get params "after")
|
||||
:before (get params "before")
|
||||
:cursor (get params "cursor")
|
||||
:limit limit}]
|
||||
|
||||
(when (str/blank? query)
|
||||
(throw (ex-info "Search query is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
(let [result (case type
|
||||
"messages" {:messages (search-messages ds user-id query opts)}
|
||||
"channels" {:channels (search-channels ds user-id query opts)}
|
||||
"users" {:users (search-users ds user-id query opts)}
|
||||
;; Default: search all types
|
||||
{:messages (search-messages ds user-id query opts)
|
||||
:channels (search-channels ds user-id query opts)
|
||||
:users (search-users ds user-id query opts)})]
|
||||
(mw/json-response result))))
|
||||
@@ -0,0 +1,95 @@
|
||||
(ns ajet.chat.api.handlers.upload
|
||||
"File upload handler.
|
||||
|
||||
Accepts multipart form data with an image file. Validates content-type
|
||||
and size, uploads to MinIO/S3, creates an attachment record in the DB,
|
||||
and returns the attachment metadata."
|
||||
(:require [clojure.java.io :as io]
|
||||
[clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.storage :as storage]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- uuid [] (str (UUID/randomUUID)))
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-channel-member! [ds channel-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:user-id]
|
||||
:from [:channel-members]
|
||||
:where [:and
|
||||
[:= :channel-id [:cast channel-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this channel"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handler
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn upload-file
|
||||
"POST /api/channels/:id/upload
|
||||
Upload an image file. Returns attachment metadata.
|
||||
|
||||
Expects a multipart form with a 'file' part."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
s3 (get-in request [:system :s3])
|
||||
bucket (get-in request [:system :bucket])
|
||||
channel-id (get-in request [:path-params :id])]
|
||||
|
||||
(check-channel-member! ds channel-id user-id)
|
||||
|
||||
;; Extract multipart file from request
|
||||
;; Ring multipart puts files in :multipart-params or :params with :tempfile
|
||||
(let [file-param (or (get-in request [:multipart-params "file"])
|
||||
(get-in request [:params :file]))
|
||||
_ (when-not file-param
|
||||
(throw (ex-info "No file uploaded. Send a multipart form with a 'file' field."
|
||||
{:type :ajet.chat/validation-error})))
|
||||
{:keys [filename content-type tempfile size]} file-param
|
||||
size-bytes (or size (when tempfile (.length tempfile)))]
|
||||
|
||||
;; Validate content type and size
|
||||
(storage/validate-upload! content-type size-bytes)
|
||||
|
||||
;; Generate storage key
|
||||
(let [attachment-id (uuid)
|
||||
key (storage/storage-key attachment-id filename)
|
||||
file-bytes (if tempfile
|
||||
(let [data (byte-array size-bytes)]
|
||||
(with-open [is (io/input-stream tempfile)]
|
||||
(.read is data))
|
||||
data)
|
||||
(throw (ex-info "Uploaded file is missing tempfile"
|
||||
{:type :ajet.chat/validation-error})))]
|
||||
|
||||
;; Upload to S3
|
||||
(storage/upload! s3 bucket key file-bytes content-type)
|
||||
|
||||
;; Create attachment record (not linked to a message yet)
|
||||
(db/execute! ds
|
||||
{:insert-into :attachments
|
||||
:values [{:id [:cast attachment-id :uuid]
|
||||
:message-id [:cast "00000000-0000-0000-0000-000000000000" :uuid] ;; placeholder
|
||||
:filename filename
|
||||
:content-type content-type
|
||||
:size-bytes size-bytes
|
||||
:storage-key key}]})
|
||||
|
||||
(mw/json-response 201
|
||||
{:id attachment-id
|
||||
:filename filename
|
||||
:content-type content-type
|
||||
:size-bytes size-bytes
|
||||
:url (str "/files/" attachment-id "/" filename)})))))
|
||||
@@ -0,0 +1,202 @@
|
||||
(ns ajet.chat.api.handlers.users
|
||||
"User profile and community member management handlers.
|
||||
|
||||
Covers: get/update own profile, view other users, list community members,
|
||||
update member roles/nicknames, and kick members."
|
||||
(:require [clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.api.middleware :as mw]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-membership! [ds community-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this community"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-role! [ds community-id user-id required-role]
|
||||
(let [member (check-membership! ds community-id user-id)
|
||||
role (:role member)
|
||||
hierarchy {"owner" 3 "admin" 2 "member" 1}
|
||||
has-level (get hierarchy role 0)
|
||||
need-level (get hierarchy required-role 0)]
|
||||
(when (< has-level need-level)
|
||||
(throw (ex-info (str "Requires " required-role " role or higher")
|
||||
{:type :ajet.chat/forbidden})))
|
||||
member))
|
||||
|
||||
(defn- publish-event! [nats subject event-type payload]
|
||||
(try
|
||||
(eventbus/publish! nats subject event-type payload)
|
||||
(catch Exception e
|
||||
(log/error e "Failed to publish event" event-type "to" subject))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn get-me
|
||||
"GET /api/me
|
||||
Returns the authenticated user's profile."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
user (db/execute-one! ds
|
||||
{:select [:id :username :display-name :email :avatar-url
|
||||
:status-text :created-at]
|
||||
:from [:users]
|
||||
:where [:= :id [:cast user-id :uuid]]})]
|
||||
(if user
|
||||
(mw/json-response user)
|
||||
(mw/error-response 404 "NOT_FOUND" "User not found"))))
|
||||
|
||||
(defn update-me
|
||||
"PUT /api/me
|
||||
Updates display_name and/or status_text."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
params (:body-params request)
|
||||
updates (cond-> {}
|
||||
(contains? params :display_name) (assoc :display-name (:display_name params))
|
||||
(contains? params :status_text) (assoc :status-text (:status_text params)))]
|
||||
(when (seq updates)
|
||||
(db/execute! ds
|
||||
{:update :users
|
||||
:set updates
|
||||
:where [:= :id [:cast user-id :uuid]]}))
|
||||
|
||||
(let [user (db/execute-one! ds
|
||||
{:select [:id :username :display-name :email :avatar-url
|
||||
:status-text :created-at]
|
||||
:from [:users]
|
||||
:where [:= :id [:cast user-id :uuid]]})]
|
||||
(mw/json-response user))))
|
||||
|
||||
(defn get-user
|
||||
"GET /api/users/:id
|
||||
Returns public profile for any user."
|
||||
[request]
|
||||
(let [_ (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
user-id (get-in request [:path-params :id])
|
||||
user (db/execute-one! ds
|
||||
{:select [:id :username :display-name :avatar-url :status-text :created-at]
|
||||
:from [:users]
|
||||
:where [:= :id [:cast user-id :uuid]]})]
|
||||
(if user
|
||||
(mw/json-response user)
|
||||
(mw/error-response 404 "NOT_FOUND" "User not found"))))
|
||||
|
||||
(defn list-community-members
|
||||
"GET /api/communities/:cid/members
|
||||
Lists all members of a community with roles and nicknames."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
_ (check-membership! ds community-id user-id)
|
||||
members (db/execute! ds
|
||||
{:select [:u.id :u.username :u.display-name :u.avatar-url
|
||||
:u.status-text :cm.role :cm.nickname :cm.joined-at]
|
||||
:from [[:community-members :cm]]
|
||||
:join [[:users :u] [:= :u.id :cm.user-id]]
|
||||
:where [:= :cm.community-id [:cast community-id :uuid]]
|
||||
:order-by [[:u.username :asc]]})]
|
||||
(mw/json-response members)))
|
||||
|
||||
(defn update-community-member
|
||||
"PUT /api/communities/:cid/members/:uid
|
||||
Update a member's nickname or role. Admin+ for nickname, Owner for role."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
target-uid (get-in request [:path-params :uid])
|
||||
params (:body-params request)
|
||||
actor-member (check-role! ds community-id user-id "admin")]
|
||||
|
||||
;; Verify target is a member
|
||||
(let [target-member (check-membership! ds community-id target-uid)]
|
||||
|
||||
;; Role changes require owner permission
|
||||
(when (contains? params :role)
|
||||
(when (not= (:role actor-member) "owner")
|
||||
(throw (ex-info "Only the owner can change roles"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
;; Cannot change owner's role
|
||||
(when (= (:role target-member) "owner")
|
||||
(throw (ex-info "Cannot change the owner's role"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(let [updates (cond-> {}
|
||||
(contains? params :nickname) (assoc :nickname (:nickname params))
|
||||
(contains? params :role) (assoc :role (:role params)))]
|
||||
(when (seq updates)
|
||||
(db/execute! ds
|
||||
{:update :community-members
|
||||
:set updates
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast target-uid :uuid]]]}))
|
||||
|
||||
(let [updated (db/execute-one! ds
|
||||
{:select [:u.id :u.username :u.display-name :cm.role :cm.nickname]
|
||||
:from [[:community-members :cm]]
|
||||
:join [[:users :u] [:= :u.id :cm.user-id]]
|
||||
:where [:and
|
||||
[:= :cm.community-id [:cast community-id :uuid]]
|
||||
[:= :cm.user-id [:cast target-uid :uuid]]]})]
|
||||
(mw/json-response updated))))))
|
||||
|
||||
(defn kick-member
|
||||
"DELETE /api/communities/:cid/members/:uid
|
||||
Removes a member from the community. Admin+. Cannot kick owner."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
target-uid (get-in request [:path-params :uid])]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
;; Cannot kick owner
|
||||
(let [target-member (check-membership! ds community-id target-uid)]
|
||||
(when (= (:role target-member) "owner")
|
||||
(throw (ex-info "Cannot kick the community owner"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
;; Remove from all channels in this community
|
||||
(db/execute-sql! ds
|
||||
["DELETE FROM channel_members WHERE user_id = ?::uuid AND channel_id IN (SELECT id FROM channels WHERE community_id = ?::uuid)"
|
||||
target-uid community-id])
|
||||
|
||||
;; Remove community membership
|
||||
(db/execute! ds
|
||||
{:delete-from :community-members
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast target-uid :uuid]]]})
|
||||
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:member/kicked
|
||||
{:community-id community-id
|
||||
:user-id target-uid
|
||||
:kicked-by user-id})
|
||||
|
||||
(mw/json-response 204 nil)))
|
||||
@@ -0,0 +1,219 @@
|
||||
(ns ajet.chat.api.handlers.webhooks
|
||||
"Webhook handlers: create, list, delete, and incoming message posting.
|
||||
|
||||
Webhooks allow external services to post messages to channels.
|
||||
Each webhook has a secret token used for authentication."
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]
|
||||
[java.security SecureRandom MessageDigest]
|
||||
[java.util Base64]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- uuid [] (str (UUID/randomUUID)))
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-role! [ds community-id user-id required-role]
|
||||
(let [member (or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this community"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
role (:role member)
|
||||
hierarchy {"owner" 3 "admin" 2 "member" 1}
|
||||
has-level (get hierarchy role 0)
|
||||
need-level (get hierarchy required-role 0)]
|
||||
(when (< has-level need-level)
|
||||
(throw (ex-info (str "Requires " required-role " role or higher")
|
||||
{:type :ajet.chat/forbidden})))
|
||||
member))
|
||||
|
||||
(defn- generate-token
|
||||
"Generate a random token string."
|
||||
[]
|
||||
(let [bytes (byte-array 32)
|
||||
sr (SecureRandom.)]
|
||||
(.nextBytes sr bytes)
|
||||
(.encodeToString (Base64/getUrlEncoder) bytes)))
|
||||
|
||||
(defn- hash-token
|
||||
"SHA-256 hash a token for storage."
|
||||
[token]
|
||||
(let [md (MessageDigest/getInstance "SHA-256")
|
||||
bytes (.digest md (.getBytes token "UTF-8"))]
|
||||
(.encodeToString (Base64/getUrlEncoder) bytes)))
|
||||
|
||||
(defn- publish-event! [nats subject event-type payload]
|
||||
(try
|
||||
(eventbus/publish! nats subject event-type payload)
|
||||
(catch Exception e
|
||||
(log/error e "Failed to publish event" event-type "to" subject))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn create-webhook
|
||||
"POST /api/communities/:cid/webhooks
|
||||
Creates a webhook. Admin+ only. Returns the token once."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
params (:body-params request)
|
||||
name-v (:name params)
|
||||
channel-id (:channel_id params)
|
||||
avatar-url (:avatar_url params)]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
(when (str/blank? name-v)
|
||||
(throw (ex-info "Webhook name is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
(when (str/blank? channel-id)
|
||||
(throw (ex-info "channel_id is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Verify channel exists and belongs to community
|
||||
(let [channel (db/execute-one! ds
|
||||
{:select [:id :community-id]
|
||||
:from [:channels]
|
||||
:where [:and
|
||||
[:= :id [:cast channel-id :uuid]]
|
||||
[:= :community-id [:cast community-id :uuid]]]})]
|
||||
(when-not channel
|
||||
(throw (ex-info "Channel not found in this community"
|
||||
{:type :ajet.chat/not-found}))))
|
||||
|
||||
(let [webhook-id (uuid)
|
||||
token (generate-token)
|
||||
token-h (hash-token token)]
|
||||
|
||||
(db/execute! ds
|
||||
{:insert-into :webhooks
|
||||
:values [(cond-> {:id [:cast webhook-id :uuid]
|
||||
:community-id [:cast community-id :uuid]
|
||||
:channel-id [:cast channel-id :uuid]
|
||||
:name name-v
|
||||
:token-hash token-h
|
||||
:created-by [:cast user-id :uuid]}
|
||||
avatar-url (assoc :avatar-url avatar-url))]})
|
||||
|
||||
(let [webhook (db/execute-one! ds
|
||||
{:select [:id :community-id :channel-id :name :avatar-url :created-at]
|
||||
:from [:webhooks]
|
||||
:where [:= :id [:cast webhook-id :uuid]]})]
|
||||
;; Return the token once (not stored in plaintext)
|
||||
(mw/json-response 201 (assoc webhook :token token))))))
|
||||
|
||||
(defn list-webhooks
|
||||
"GET /api/communities/:cid/webhooks
|
||||
Lists webhooks (without tokens). Admin+ only."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :cid])]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
(let [webhooks (db/execute! ds
|
||||
{:select [:id :community-id :channel-id :name :avatar-url
|
||||
:created-by :created-at]
|
||||
:from [:webhooks]
|
||||
:where [:= :community-id [:cast community-id :uuid]]
|
||||
:order-by [[:created-at :desc]]})]
|
||||
(mw/json-response webhooks))))
|
||||
|
||||
(defn delete-webhook
|
||||
"DELETE /api/webhooks/:id
|
||||
Deletes a webhook. Admin+ of the webhook's community."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
webhook-id (get-in request [:path-params :id])
|
||||
webhook (or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:webhooks]
|
||||
:where [:= :id [:cast webhook-id :uuid]]})
|
||||
(throw (ex-info "Webhook not found"
|
||||
{:type :ajet.chat/not-found})))]
|
||||
|
||||
(check-role! ds (str (:community-id webhook)) user-id "admin")
|
||||
|
||||
(db/execute! ds
|
||||
{:delete-from :webhooks
|
||||
:where [:= :id [:cast webhook-id :uuid]]})
|
||||
|
||||
(mw/json-response 204 nil)))
|
||||
|
||||
(defn incoming-webhook
|
||||
"POST /api/webhooks/:id/incoming
|
||||
Post a message via webhook. Authenticates via Bearer token."
|
||||
[request]
|
||||
(let [ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
webhook-id (get-in request [:path-params :id])
|
||||
params (:body-params request)
|
||||
content (:content params)
|
||||
username (:username params)
|
||||
avatar-url (:avatar_url params)]
|
||||
|
||||
;; Authenticate via Bearer token
|
||||
(let [auth-header (get-in request [:headers "authorization"])
|
||||
token (when (and auth-header (str/starts-with? auth-header "Bearer "))
|
||||
(subs auth-header 7))
|
||||
_ (when-not token
|
||||
(throw (ex-info "Authorization required"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
token-h (hash-token token)
|
||||
webhook (or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:webhooks]
|
||||
:where [:and
|
||||
[:= :id [:cast webhook-id :uuid]]
|
||||
[:= :token-hash token-h]]})
|
||||
(throw (ex-info "Invalid webhook token"
|
||||
{:type :ajet.chat/forbidden})))]
|
||||
|
||||
(when (str/blank? content)
|
||||
(throw (ex-info "content is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Create message in the webhook's channel
|
||||
(let [message-id (uuid)
|
||||
channel-id (str (:channel-id webhook))
|
||||
community-id (str (:community-id webhook))
|
||||
body-md (str content)]
|
||||
|
||||
(db/execute! ds
|
||||
{:insert-into :messages
|
||||
:values [{:id [:cast message-id :uuid]
|
||||
:channel-id [:cast channel-id :uuid]
|
||||
;; webhook messages have nil user_id (webhook is the author)
|
||||
:body-md body-md}]})
|
||||
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:message/created
|
||||
{:message-id message-id
|
||||
:channel-id channel-id
|
||||
:community-id community-id
|
||||
:body-md body-md
|
||||
:webhook-id webhook-id
|
||||
:webhook-name (or username (:name webhook))
|
||||
:avatar-url (or avatar-url (:avatar-url webhook))})
|
||||
|
||||
(mw/json-response 204 nil)))))
|
||||
@@ -0,0 +1,177 @@
|
||||
(ns ajet.chat.api.middleware
|
||||
"Ring middleware pipeline for the API service.
|
||||
|
||||
Provides:
|
||||
- Exception handling (catch-all -> 500)
|
||||
- User context extraction from Auth GW headers
|
||||
- Ban check for community-scoped endpoints
|
||||
- Mute check for write endpoints
|
||||
- JSON response helpers"
|
||||
(:require [clojure.data.json :as json]
|
||||
[clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Response Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn json-response
|
||||
"Build a Ring response with JSON content type."
|
||||
([status body]
|
||||
{:status status
|
||||
:headers {"Content-Type" "application/json"}
|
||||
:body (json/write-str body)})
|
||||
([body]
|
||||
(json-response 200 body)))
|
||||
|
||||
(defn error-response
|
||||
"Build a JSON error response.
|
||||
code: string like \"NOT_FOUND\", \"FORBIDDEN\"
|
||||
message: human-readable string
|
||||
details: optional map of additional context"
|
||||
([status code message]
|
||||
(error-response status code message {}))
|
||||
([status code message details]
|
||||
(json-response status {:error {:code code
|
||||
:message message
|
||||
:details details}})))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Middleware: Exception Handler
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn wrap-exception-handler
|
||||
"Catch-all exception handler. Returns 500 JSON error for unhandled exceptions."
|
||||
[handler]
|
||||
(fn [request]
|
||||
(try
|
||||
(handler request)
|
||||
(catch clojure.lang.ExceptionInfo e
|
||||
(let [data (ex-data e)
|
||||
typ (:type data)]
|
||||
(cond
|
||||
(= typ :ajet.chat/validation-error)
|
||||
(error-response 422 "VALIDATION_ERROR" (ex-message e) (dissoc data :type))
|
||||
|
||||
(= typ :ajet.chat/not-found)
|
||||
(error-response 404 "NOT_FOUND" (ex-message e) (dissoc data :type))
|
||||
|
||||
(= typ :ajet.chat/forbidden)
|
||||
(error-response 403 "FORBIDDEN" (ex-message e) (dissoc data :type))
|
||||
|
||||
(= typ :ajet.chat/conflict)
|
||||
(error-response 409 "CONFLICT" (ex-message e) (dissoc data :type))
|
||||
|
||||
(= typ :ajet.chat/gone)
|
||||
(error-response 410 "GONE" (ex-message e) (dissoc data :type))
|
||||
|
||||
:else
|
||||
(do
|
||||
(log/error e "Unhandled exception" (pr-str data))
|
||||
(error-response 500 "INTERNAL_ERROR" "An internal error occurred")))))
|
||||
(catch Exception e
|
||||
(log/error e "Unhandled exception")
|
||||
(error-response 500 "INTERNAL_ERROR" "An internal error occurred")))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Middleware: User Context
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn wrap-user-context
|
||||
"Extract X-User-Id, X-User-Role, X-Community-Id from request headers
|
||||
into the request map as :user-id, :user-role, :community-id."
|
||||
[handler]
|
||||
(fn [request]
|
||||
(let [headers (:headers request)
|
||||
user-id (get headers "x-user-id")
|
||||
user-role (get headers "x-user-role")
|
||||
community-id (get headers "x-community-id")]
|
||||
(handler (cond-> request
|
||||
user-id (assoc :user-id user-id)
|
||||
user-role (assoc :user-role user-role)
|
||||
community-id (assoc :community-id community-id))))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Middleware: System Injection
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn wrap-system
|
||||
"Inject system components into the request map under :system."
|
||||
[handler system]
|
||||
(fn [request]
|
||||
(handler (assoc request :system system))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Middleware: Ban Check
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn wrap-ban-check
|
||||
"For community-scoped endpoints, check if the user is banned.
|
||||
Rejects with 403 if banned. Requires :user-id and :community-id on request."
|
||||
[handler]
|
||||
(fn [request]
|
||||
(let [user-id (:user-id request)
|
||||
community-id (or (:community-id request)
|
||||
(get-in request [:path-params :cid]))
|
||||
ds (get-in request [:system :ds])]
|
||||
(if (and user-id community-id ds)
|
||||
(let [banned? (db/execute-one! ds
|
||||
{:select [[:1 :exists]]
|
||||
:from [:bans]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})]
|
||||
(if banned?
|
||||
(error-response 403 "BANNED" "You are banned from this community")
|
||||
(handler request)))
|
||||
(handler request)))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Middleware: Mute Check
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn wrap-mute-check
|
||||
"For write endpoints (POST/PUT/DELETE on messages, reactions, etc.),
|
||||
check if the user is muted. Rejects with 403 if actively muted."
|
||||
[handler]
|
||||
(fn [request]
|
||||
(let [user-id (:user-id request)
|
||||
community-id (or (:community-id request)
|
||||
(get-in request [:path-params :cid]))
|
||||
ds (get-in request [:system :ds])]
|
||||
(if (and user-id community-id ds)
|
||||
(let [muted? (db/execute-one! ds
|
||||
{:select [[:1 :exists]]
|
||||
:from [:mutes]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]
|
||||
[:or
|
||||
[:= :expires-at nil]
|
||||
[:> :expires-at [:now]]]]})]
|
||||
(if muted?
|
||||
(error-response 403 "MUTED" "You are muted in this community")
|
||||
(handler request)))
|
||||
(handler request)))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Middleware: JSON Body Parsing
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn wrap-json-body
|
||||
"Parse JSON request body into :body-params."
|
||||
[handler]
|
||||
(fn [request]
|
||||
(let [content-type (get-in request [:headers "content-type"] "")]
|
||||
(if (and (:body request)
|
||||
(or (.contains content-type "application/json")
|
||||
(.contains content-type "text/json")))
|
||||
(try
|
||||
(let [body-str (slurp (:body request))
|
||||
parsed (when-not (str/blank? body-str)
|
||||
(json/read-str body-str :key-fn keyword))]
|
||||
(handler (assoc request :body-params (or parsed {}))))
|
||||
(catch Exception _
|
||||
(error-response 400 "INVALID_JSON" "Request body is not valid JSON")))
|
||||
(handler (assoc request :body-params {}))))))
|
||||
@@ -0,0 +1,177 @@
|
||||
(ns ajet.chat.api.routes
|
||||
"Reitit router with all API endpoint groups.
|
||||
|
||||
All routes organized by feature domain. Middleware pipeline applied
|
||||
in the order specified by the PRD."
|
||||
(:require [reitit.ring :as ring]
|
||||
[reitit.ring.middleware.parameters :as parameters]
|
||||
[reitit.ring.middleware.muuntaja :as muuntaja]
|
||||
[ajet.chat.api.middleware :as mw]
|
||||
[ajet.chat.shared.logging :as logging]
|
||||
[ajet.chat.api.handlers.communities :as communities]
|
||||
[ajet.chat.api.handlers.channels :as channels]
|
||||
[ajet.chat.api.handlers.categories :as categories]
|
||||
[ajet.chat.api.handlers.messages :as messages]
|
||||
[ajet.chat.api.handlers.reactions :as reactions]
|
||||
[ajet.chat.api.handlers.dms :as dms]
|
||||
[ajet.chat.api.handlers.users :as users]
|
||||
[ajet.chat.api.handlers.notifications :as notifications]
|
||||
[ajet.chat.api.handlers.presence :as presence]
|
||||
[ajet.chat.api.handlers.search :as search]
|
||||
[ajet.chat.api.handlers.invites :as invites]
|
||||
[ajet.chat.api.handlers.webhooks :as webhooks]
|
||||
[ajet.chat.api.handlers.commands :as commands]
|
||||
[ajet.chat.api.handlers.upload :as upload]
|
||||
[ajet.chat.api.handlers.health :as health]
|
||||
[ajet.chat.api.handlers.admin :as admin]))
|
||||
|
||||
(defn router
|
||||
"Build the reitit router with all API routes."
|
||||
[]
|
||||
(ring/router
|
||||
[["/api"
|
||||
|
||||
;; Health check - no auth, no ban/mute check
|
||||
["/health" {:get {:handler health/health-check}}]
|
||||
|
||||
;; Communities
|
||||
["/communities"
|
||||
["" {:get {:handler communities/list-communities}
|
||||
:post {:handler communities/create-community}}]
|
||||
["/:id" {:get {:handler communities/get-community}
|
||||
:put {:handler communities/update-community}
|
||||
:delete {:handler communities/delete-community}}]
|
||||
|
||||
;; Community-scoped channels
|
||||
["/:cid/channels" {:get {:handler channels/list-channels
|
||||
:middleware [mw/wrap-ban-check]}
|
||||
:post {:handler channels/create-channel
|
||||
:middleware [mw/wrap-ban-check mw/wrap-mute-check]}}]
|
||||
|
||||
;; Community-scoped categories
|
||||
["/:cid/categories" {:get {:handler categories/list-categories
|
||||
:middleware [mw/wrap-ban-check]}
|
||||
:post {:handler categories/create-category
|
||||
:middleware [mw/wrap-ban-check mw/wrap-mute-check]}}]
|
||||
|
||||
;; Community members
|
||||
["/:cid/members"
|
||||
["" {:get {:handler users/list-community-members
|
||||
:middleware [mw/wrap-ban-check]}}]
|
||||
["/:uid" {:put {:handler users/update-community-member
|
||||
:middleware [mw/wrap-ban-check]}
|
||||
:delete {:handler users/kick-member
|
||||
:middleware [mw/wrap-ban-check]}}]]
|
||||
|
||||
;; Presence
|
||||
["/:cid/presence" {:get {:handler presence/get-presence
|
||||
:middleware [mw/wrap-ban-check]}}]
|
||||
|
||||
;; Invites
|
||||
["/:cid/invites"
|
||||
["" {:post {:handler invites/create-invite
|
||||
:middleware [mw/wrap-ban-check]}
|
||||
:get {:handler invites/list-invites
|
||||
:middleware [mw/wrap-ban-check]}}]
|
||||
["/direct" {:post {:handler invites/direct-invite
|
||||
:middleware [mw/wrap-ban-check]}}]]
|
||||
|
||||
;; Webhooks
|
||||
["/:cid/webhooks" {:post {:handler webhooks/create-webhook
|
||||
:middleware [mw/wrap-ban-check]}
|
||||
:get {:handler webhooks/list-webhooks
|
||||
:middleware [mw/wrap-ban-check]}}]]
|
||||
|
||||
;; Channels (by channel ID)
|
||||
["/channels/:id"
|
||||
["" {:get {:handler channels/get-channel}
|
||||
:put {:handler channels/update-channel}
|
||||
:delete {:handler channels/delete-channel}}]
|
||||
["/join" {:post {:handler channels/join-channel
|
||||
:middleware [mw/wrap-ban-check]}}]
|
||||
["/leave" {:post {:handler channels/leave-channel}}]
|
||||
["/members" {:get {:handler channels/list-channel-members}}]
|
||||
["/messages" {:get {:handler messages/list-messages}
|
||||
:post {:handler messages/send-message
|
||||
:middleware [mw/wrap-mute-check]}}]
|
||||
["/upload" {:post {:handler upload/upload-file}}]
|
||||
["/read" {:post {:handler messages/mark-read}}]]
|
||||
|
||||
;; Categories (by category ID)
|
||||
["/categories/:id" {:put {:handler categories/update-category}
|
||||
:delete {:handler categories/delete-category}}]
|
||||
|
||||
;; Messages (by message ID)
|
||||
["/messages/:id"
|
||||
["" {:get {:handler messages/get-message}
|
||||
:put {:handler messages/edit-message
|
||||
:middleware [mw/wrap-mute-check]}
|
||||
:delete {:handler messages/delete-message}}]
|
||||
["/thread" {:get {:handler messages/get-thread}}]
|
||||
["/reactions"
|
||||
["" {:get {:handler reactions/list-reactions}}]
|
||||
["/:emoji" {:put {:handler reactions/add-reaction
|
||||
:middleware [mw/wrap-mute-check]}
|
||||
:delete {:handler reactions/remove-reaction}}]]]
|
||||
|
||||
;; DMs
|
||||
["/dms"
|
||||
["" {:get {:handler dms/list-dms}
|
||||
:post {:handler dms/create-dm}}]
|
||||
["/group" {:post {:handler dms/create-group-dm}}]]
|
||||
|
||||
;; Users & Profile
|
||||
["/me" {:get {:handler users/get-me}
|
||||
:put {:handler users/update-me}}]
|
||||
["/users/:id" {:get {:handler users/get-user}}]
|
||||
|
||||
;; Notifications
|
||||
["/notifications"
|
||||
["" {:get {:handler notifications/list-notifications}}]
|
||||
["/read" {:post {:handler notifications/mark-read}}]
|
||||
["/unread-count" {:get {:handler notifications/unread-count}}]]
|
||||
|
||||
;; Presence & Heartbeat
|
||||
["/heartbeat" {:post {:handler presence/heartbeat}}]
|
||||
|
||||
;; Search
|
||||
["/search" {:get {:handler search/search-handler}}]
|
||||
|
||||
;; Invites (by invite ID / code)
|
||||
["/invites/:id" {:delete {:handler invites/revoke-invite}}]
|
||||
["/invites/:code/accept" {:post {:handler invites/accept-invite}}]
|
||||
|
||||
;; Webhooks (by webhook ID)
|
||||
["/webhooks/:id"
|
||||
["" {:delete {:handler webhooks/delete-webhook}}]
|
||||
["/incoming" {:post {:handler webhooks/incoming-webhook}}]]
|
||||
|
||||
;; Slash commands
|
||||
["/commands" {:post {:handler commands/execute-command}}]
|
||||
|
||||
;; Admin — OAuth provider management
|
||||
["/admin/oauth-providers"
|
||||
["" {:get {:handler admin/list-providers}
|
||||
:post {:handler admin/create-provider}}]
|
||||
["/:id" {:put {:handler admin/update-provider}
|
||||
:delete {:handler admin/delete-provider}}]]]]
|
||||
|
||||
{:data {:middleware [parameters/parameters-middleware]}}))
|
||||
|
||||
(defn app
|
||||
"Build the full Ring handler with middleware stack."
|
||||
[system]
|
||||
(let [handler (ring/ring-handler
|
||||
(router)
|
||||
;; Default handler for unmatched routes
|
||||
(ring/create-default-handler
|
||||
{:not-found
|
||||
(constantly (mw/error-response 404 "NOT_FOUND" "Route not found"))
|
||||
:method-not-allowed
|
||||
(constantly (mw/error-response 405 "METHOD_NOT_ALLOWED" "Method not allowed"))}))]
|
||||
(-> handler
|
||||
mw/wrap-user-context
|
||||
mw/wrap-json-body
|
||||
(mw/wrap-system system)
|
||||
logging/wrap-request-logging
|
||||
mw/wrap-exception-handler)))
|
||||
Reference in New Issue
Block a user