init codebase

This commit is contained in:
2026-02-17 17:30:45 -05:00
parent a3b28549b4
commit f7e2755a91
175 changed files with 21600 additions and 232 deletions
+12
View File
@@ -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
View File
@@ -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")}}}
+23
View File
@@ -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;
+124 -2
View File
@@ -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))
+125
View File
@@ -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)))
+331
View File
@@ -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))))
+451
View File
@@ -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)))
+169
View File
@@ -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)))))
+73
View File
@@ -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})))
+297
View File
@@ -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)))))))
+395
View File
@@ -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))))
+132
View File
@@ -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))))
+95
View File
@@ -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)})))))
+202
View File
@@ -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)))
+219
View File
@@ -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)))))
+177
View File
@@ -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 {}))))))
+177
View File
@@ -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)))