Techadamia Backend Documentation

Techadamia Backend - Code Documentation

Package-by-package reference of the exported (and a few notable unexported) functions and types, with signatures verified against the source.

Tip

This is the function-level reference. For the bigger picture see ARCHITECTURE.md, and for where files live see CODEBASE_STRUCTURE.md.

Table of Contents

  1. Main Package (cmd)
  2. API Package (internal/api)
  3. Service Package (internal/service)
  4. Middleware Package (internal/middleware)
  5. Realtime Package (internal/realtime)
  6. Mail Package (internal/mail)
  7. SQL Package (internal/sql)
  8. Utils Package (internal/utils)
  9. Migrate Package (internal/migrate)
  10. Types & Enums

Main Package (cmd)

Location: cmd/main.go (package main)

func main()

Entry point. If the first CLI arg is healthcheck, it runs runHealthcheck() and returns. Otherwise it loads .env, configures slog, validates config, builds the DB pool, runs migrations, wires service/handlers/router, starts the session-cleanup and orphan-media-GC goroutines, and serves HTTP until graceful shutdown. Exits 1 on config or database errors.

It also builds the realtime hub and the SMTP mailer before serving: realtime.NewHub() is registered on the service as its Notifier, and mail.LoadFromEnv() is handed to the handler via SetMailer. SMTP is optional — see Mail Package for what changes when it is absent.

func loadConfig() (appConfig, error)

Reads and validates all configuration from the environment, returning an appConfig (DatabaseURL, AppPort, MediaStorageDir, SessionCleanupInterval). It also eagerly validates CORS, every rate-limit spec, the Redis URL, media limits/MIME, trusted proxies, and HSTS values so the process fails fast on misconfiguration.

func newDBPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error)

Parses databaseURL and returns a tuned pgxpool.Pool: MaxConns 20, MinConns 2, MaxConnLifetime 1h, MaxConnIdleTime 30m, HealthCheckPeriod 1m, plus a 10% lifetime jitter. Every value is env-overridable (DB_MAX_CONNS, DB_MIN_CONNS, DB_MAX_CONN_LIFETIME, DB_MAX_CONN_IDLE_TIME, DB_HEALTH_CHECK_PERIOD).

func runHealthcheck()

Backs the container HEALTHCHECK. Probes http://127.0.0.1:$APP_PORT/health (default port 8080) with a 2s timeout and exits 0 on HTTP 200, otherwise 1.

Other helpers

  • func startSessionCleanup(ctx, svc *service.Service, interval time.Duration) - ticker goroutine calling svc.DeleteExpiredSessions and svc.DeleteExpiredAuthTokens (password-reset and email-verification tokens share the sweep); no-op if interval <= 0.
  • func validateTrustedProxies(raw string) error - validates CSV of IPs/CIDRs.
  • func ensureWritableDir(path string) error - creates the media dir and verifies it is writable.
  • func envInt32(name string, fallback int32) int32, func envDuration(name string, fallback time.Duration) time.Duration - env parsing with fallbacks.

API Package (internal/api)

Location: internal/api/

Routing (routes.go)

type Routes struct { Router *gin.Engine; Handler *APIHandler }

func NewRouter(handler *APIHandler, opts ...gin.OptionFunc) *Routes

Creates a Gin engine with gin.Recovery() and any optional engine options.

func (r *Routes) RegisterRoutes() error

Loads CORS config and trusted proxies, optionally connects Redis (and registers it on the handler as the readiness checker), builds the five rate limiters (login/register/media/admin/write) via buildLimiter, installs the global middleware chain, and registers the public, protected/author, and admin route groups. Returns an error on config/Redis failures.

Unexported helpers: maxRequestBodyBytes() int64 (default 1 MiB, override MAX_REQUEST_BODY_BYTES) and buildLimiter(client *redis.Client, spec utils.RateLimitSpec, prefix string) middleware.Limiter (Redis limiter when a client is present, otherwise in-memory).

Handlers (handlers.go)

type APIHandler struct

type APIHandler struct {
    Service *service.Service
    ready   atomic.Bool
    redis   readinessPinger // nil when Redis is not configured

    hub    *realtime.Hub // SSE pub/sub; also wired into the service as its Notifier
    mailer *mail.Mailer  // nil-safe: Enabled() is false when SMTP is not configured

    mediaOnce sync.Once
    mediaCfg  mediaConfig
    mediaErr  error
}
  • func NewAPIHandler(svc *service.Service) *APIHandler - also creates the realtime hub and registers it on the service as its Notifier.
  • func (h *APIHandler) SetReady(ready bool)
  • func (h *APIHandler) SetRedisChecker(p readinessPinger) - registers a Redis client so /ready also pings it.
  • func (h *APIHandler) SetMailer(m *mail.Mailer) - registers the SMTP mailer. A nil mailer is safe; Enabled() reports false and email sends become log lines.

Endpoint methods

All take (c *gin.Context):

  • Health - GET /health, static {"status":"ok"}.
  • Ready - GET /ready, returns starting until ready, then pings DB and (if configured) Redis.
  • RecentPosts - GET /recent, optional time_interval (days, default 7), paginated (default 20, max 100). Returns PostResponse list + PaginationMeta.
  • PostsSlug - GET /posts/:slug, published post by slug with media. Behind OptionalAuthMiddleware: when a valid session accompanies the request the response also carries liked_by_me.
  • ListCategories - GET /categories, paginated (default 50, max 200).
  • Register - POST /register, validated by ValidateRegisterParams. The optional role form field selects the account kind (author, the default, or reader); anything else is a 400. Authors are created pending and the admins are notified (NotifyAdminsOfPendingUser). Readers get a verification email — or, when SMTP is not configured, are activated immediately so local development works without a mail server. The response carries verification_required so the client knows which path was taken.
  • Login - POST /login, sets session_id and csrf_token cookies, returns LoginResponse.
  • Logout - POST /logout, deletes the session and clears both cookies.
  • CreatePost - POST /posts, author/admin; parses category_ids/media_ids.
  • UpdatePost - PUT /posts/:slug; admins bypass ownership.
  • DeletePost - DELETE /posts/:slug; admins bypass ownership (soft delete).
  • UploadMedia - POST /media; multipart file, MIME-sniffed and size-capped.
  • ListUsers - GET /users, admin; paginated, returns UserResponse list.
  • ListPendingUsers - GET /users/pending, admin; pending accounts.
  • DeleteUser - DELETE /users/:id, admin; self-delete blocked; last-admin guard returns 409.
  • UpdateUserStatus - PUT /users/:id/status, admin; self-change blocked; deleted status routes to soft delete; last-admin guard returns 409.
  • ArchivePost - PUT /posts/archive/:id, admin.
  • AddCategory - POST /categories, admin.
  • BootstrapAdmin - POST /admin/bootstrap; requires X-Bootstrap-Token == BOOTSTRAP_TOKEN (constant-time), succeeds only while no admin exists.

Helpers

  • paginationParams(c *gin.Context, defaultLimit, maxLimit int) (int, int) - parses ?limit/?offset with a default and a hard cap on limit; negative offsets clamp to 0.
  • getUserIDFromContext, getSessionIDFromContext, getUserRoleFromContext - pull auth values set by RequireAuthMiddleware.
  • parseUUIDListFromForm, parseUUIDStrings, uniqueUUIDs - parse/dedupe UUID form lists.
  • getUsernameFromContext(c *gin.Context) (string, bool) - the actor's username, needed for notification payloads.
  • uuidString(id pgtype.UUID) string, sessionCookieConfig() (string, bool, http.SameSite).
  • publicAppURL() string - base URL for password-reset and verification links: APP_PUBLIC_URL if set, else the first non-wildcard entry of CORS_ALLOWED_ORIGINS, else empty. Trailing slashes are trimmed.
  • handleUniqueViolation / handleConstraintViolation - map pg error codes (23505, 23503, 23514) to 409/400 responses.
  • loadMediaConfig() (mediaConfig, error), extensionForMime(string) (string, bool) - media validation (default 10 MB; default MIMEs jpeg/png/webp).
  • isSafeStorageKey(key string) bool, contentTypeForKey(key string) string - guard and content-type resolution for GET /media/:key.
  • sendVerificationEmail(c, userID, username, email) - issues a verification token and emails the link. Without SMTP it logs the link instead. Failures are logged, never surfaced — the account exists either way and the client can retry via POST /verify-email/resend.

Account flows (authflows.go)

Password reset and email verification. Every handler here is written to be enumeration-safe: the response is identical whether or not an account matches, so none of these endpoints can be used to probe which emails are registered.

  • RequestPasswordReset - POST /password-reset/request. Emails a one-hour reset link when an active account matches; always returns the same 200.
  • ConfirmPasswordReset - POST /password-reset/confirm. Redeems the token, sets the new password, burns the account's tokens, and revokes every session (an attacker holding a stolen cookie is logged out).
  • ResendVerificationEmail - POST /verify-email/resend. Reissues a link only for accounts that still need verification.
  • VerifyEmail - POST /verify-email. Readers become active; authors are marked verified but still await admin approval.

Profiles & account self-service (profile.go)

  • Me - GET /me, the caller's own account (MeResponse).
  • UpdateMe - PUT /me, display name, bio, and avatar. Avatar media must be owned by the caller.
  • ChangePassword - PUT /me/password. Requires the current password and rotates the session, so other sessions are invalidated.
  • PublicProfile - GET /users/:user. Behind OptionalAuthMiddleware, so a logged-in viewer also gets following.
  • UserPosts - GET /users/:user/posts, that author's published posts.
  • MyPosts - GET /me/posts, the caller's own posts including drafts; optional ?status= filter.
  • resolveProfileTarget(c) (pgtype.UUID, bool) - resolves the :user path param, which accepts either a username or a UUID, and writes the 404 itself when nothing matches.

Note

The path parameter is named :user on every route under /users because Gin requires one param name per position, and the admin routes share that prefix while addressing users by UUID.

Social layer (social.go)

Comments, likes, follows, and search.

  • ListComments - GET /posts/:slug/comments, threaded via parent_comment_id, paginated.
  • CreateComment - POST /posts/:slug/comments. Notifies the post owner (comment) and, for a reply, the parent comment's author (reply).
  • UpdateComment / DeleteComment - PUT|DELETE /comments/:id. Authors edit and delete their own; admins may delete any.
  • HideComment - PUT /comments/:id/hide, admin-only moderation.
  • LikePost / UnlikePost - PUT|DELETE /posts/:slug/like. Idempotent: the response reports whether the call actually changed anything, so a double-tap is not an error. Liking notifies the post owner.
  • FollowUser / UnfollowUser - PUT|DELETE /users/:user/follow. Also idempotent; following yourself is rejected.
  • Followers / Following - GET /users/:user/followers|following, paginated.
  • SearchPosts - GET /search, full-text over published posts (?q=).
  • Helpers: resolvePublishedPost(c) (sql.GetPostBySlugRow, bool) and parseCommentID(c) (pgtype.UUID, bool), both of which write their own error response and report false.

Notifications & SSE (notifications.go)

  • ListNotifications - GET /notifications, paginated, with an unread count.
  • MarkNotificationRead - PUT /notifications/:id/read.
  • MarkAllNotificationsRead - PUT /notifications/read.
  • Events - GET /events, the Server-Sent Events stream. It clears the connection's write deadline (the server's WriteTimeout would otherwise kill a long-lived response), sets text/event-stream plus X-Accel-Buffering: no so nginx does not buffer it, subscribes to the hub, and emits a connected handshake carrying retry: 3000. A 25-second : ping heartbeat keeps intermediaries from idling the connection out. The loop exits on request cancellation or channel close, and the deferred cancel removes the subscription.

Event names on the stream: connected, notification, message.

Direct messages (messages.go)

  • ListConversations - GET /messages, one row per peer with the last message and unread count.
  • MessagesWith - GET /messages/:user, the thread with one peer, paginated.
  • SendMessage - POST /messages/:user. Validated by ValidateMessageBody; pushes a message event to the recipient's live subscriptions.
  • MarkMessagesRead - PUT /messages/:user/read, returns how many rows were marked.

Response DTOs (responses.go)

JSON shapes use uuid.UUID and *time.Time so output is a UUID string and an RFC3339 timestamp (omitted when null):

type UserResponse struct {
    UserID      uuid.UUID  `json:"user_id"`
    Username    string     `json:"username"`
    Email       string     `json:"email"`
    UserRole    string     `json:"user_role"`
    UserStatus  string     `json:"user_status"`
    CreatedAt   *time.Time `json:"created_at,omitempty"`
    LastLoginAt *time.Time `json:"last_login_at,omitempty"`
    DeletedAt   *time.Time `json:"deleted_at,omitempty"`
    PostCount   *int64     `json:"post_count,omitempty"`
}

type LoginResponse struct {
    UserID   uuid.UUID `json:"user_id"`
    Username string    `json:"username"`
    UserRole string    `json:"user_role"`
}

type CategoryResponse struct {
    CategoryID uuid.UUID `json:"category_id"`
    Name       string    `json:"name"`
    Slug       string    `json:"slug"`
}

type MediaResponse struct {
    MediaID    uuid.UUID `json:"media_id"`
    StorageKey string    `json:"storage_key"`
}

type PostResponse struct {
    PostID            uuid.UUID          `json:"post_id"`
    Title             string             `json:"title"`
    Slug              string             `json:"slug"`
    MdContent         string             `json:"md_content,omitempty"`
    Status            string             `json:"status"`
    PublishedAt       *time.Time         `json:"published_at,omitempty"`
    UpdatedAt         *time.Time         `json:"updated_at,omitempty"`
    ArchivedAt        *time.Time         `json:"archived_at,omitempty"`
    AuthorUserID      *uuid.UUID         `json:"author_user_id,omitempty"`
    AuthorUsername    string             `json:"author_username,omitempty"`
    AuthorDisplayName string             `json:"author_display_name,omitempty"`
    Categories        []CategoryResponse `json:"categories"`
    LikeCount         int64              `json:"like_count"`
    CommentCount      int64              `json:"comment_count"`
    LikedByMe         *bool              `json:"liked_by_me,omitempty"` // only for logged-in viewers
    Media             []MediaResponse    `json:"media,omitempty"`
}

type PaginationMeta struct {
    Limit  int `json:"limit"`
    Offset int `json:"offset"`
    Count  int `json:"count"`
}

The social/profile layer adds these shapes:

Type Used by Notes
MeResponse GET /me Full self view: profile fields, email_verified, follower/following counts, and unread notification/message badges.
PublicProfileResponse GET /users/:user Deliberately omits email, phone, and status. following is present only for logged-in viewers.
AuthorPostResponse /users/:user/posts, /me/posts Compact post row without body content.
SearchResultResponse GET /search Adds a highlighted excerpt.
CommentResponse comment endpoints Carries parent_comment_id for threading and the author's display name/avatar.
NotificationResponse notification endpoints read_at absent means unread.
ConversationResponse GET /messages One row per peer: last message, direction, unread count.
MessageResponse message endpoints mine is resolved per viewer rather than exposing both user IDs.
FollowUserResponse followers/following lists

Converters:

  • func toUUID(p pgtype.UUID) uuid.UUID - uuid.Nil when invalid.
  • func toTime(t pgtype.Timestamptz) *time.Time - nil when invalid.
  • func toUUIDPtr(p pgtype.UUID) *uuid.UUID, func textOrEmpty(t pgtype.Text) string - null-safe accessors for optional columns.
  • userFromListRow, userFromPendingRow, categoryFromRow, categoriesFromRows, postFromRecentRow, recentPostsResponse, postFromBySlug, authorPostFromRow, myPostFromRow, searchResultFromRow, meFromRow, publicProfileFromRow, commentFromListRow, commentFromModel, notificationFromRow, conversationFromRow, messageFromModel, followUserFromRow.
  • parseMediaJSON / parseCategoriesJSON - unmarshal the JSON-aggregated media and category arrays the post queries return.

Validation (validator.go)

type ValidationError struct {
    Field   string `json:"field"`
    Message string `json:"message"`
}

func ValidateUsername(username string) []ValidationError
func ValidatePassword(password string) []ValidationError
func ValidateRegisterParams(params sql.RegisterParams) []ValidationError
func ValidateCommentBody(body string) []ValidationError
func ValidateMessageBody(body string) []ValidationError
func ValidateProfileFields(displayName, bio string) []ValidationError
func ValidateCreatePostParams(params sql.CreatePostParams) []ValidationError
func ValidateAddCategoryParams(params sql.AddCategoryParams) []ValidationError
Validator Rules
ValidateUsername 3-50 chars; letters, digits, ., _, - only; must start and end with a letter or digit.
ValidatePassword 8-72 chars. The upper bound is bcrypt's 72-byte limit — without it, anything past 72 bytes is silently ignored at hashing time.
ValidateRegisterParams Username + password rules above, valid email, optional phone (10 digits when present).
ValidateCommentBody Non-blank, <= 5000 chars.
ValidateMessageBody Non-blank, <= 2000 chars.
ValidateProfileFields Display name <= 100 chars, bio <= 1000 chars.
ValidateCreatePostParams Non-empty title (<= 255), non-empty md_content, status in {draft, published, archived}.
ValidateAddCategoryParams Non-empty name (<= 100).

Unexported: isValidPhoneNumber(phone string) bool, usernameRe.


Service Package (internal/service)

Location: internal/service/

type Service struct

// Notifier is the realtime push hook (implemented by realtime.Hub). Delivery
// is best-effort: every event worth keeping is also persisted as a
// notifications row before Publish is called.
type Notifier interface {
    Publish(userID pgtype.UUID, event string, payload any)
}

type Service struct {
    db       *pgxpool.Pool
    queries  *sql.Queries
    notifier Notifier // nil until SetNotifier; publish() is nil-safe
}

func NewService(db *pgxpool.Pool) *Service
func (s *Service) SetNotifier(n Notifier)   // call once at startup, before serving
func (s *Service) Ping(ctx context.Context) error

The unexported publish(userID, event, payload) wrapper is a no-op when no notifier is wired, which is what keeps the service usable in tests without a hub.

Errors

ErrInvalidCredentials, ErrUserNotActive, ErrAdminAlreadyExists (auth.go); ErrInvalidSlug, ErrUnauthorizedOperation, ErrInvalidCategoryIDs, ErrInvalidMediaIDs, ErrPostNotFound (posts.go); ErrLastAdmin (users.go); ErrInvalidToken (tokens.go); ErrUserNotFound (profile.go); ErrSelfAction (social.go); ErrCommentNotFound, ErrInvalidParentComment (comments.go).

Auth & sessions (auth.go)

  • func (s *Service) Register(ctx, params sql.RegisterParams) (pgtype.UUID, error) - bcrypt-hashes the password then inserts.
  • func (s *Service) BootstrapAdmin(ctx, params sql.RegisterParams) (pgtype.UUID, error) - in one transaction, fails with ErrAdminAlreadyExists if CountAdmins > 0, otherwise inserts an admin and immediately activates it.
  • func (s *Service) Login(ctx, email, password string) (sql.LoginRow, pgtype.UUID, pgtype.Timestamptz, error) - bcrypt verify (dummy-hash compare on unknown email to flatten timing), reject non-active users, then in a transaction delete other sessions, add a new 7-day session, and UpdateLastLogin.
  • func (s *Service) GetSessionAndUser(ctx, sessionID pgtype.UUID) (sql.GetSessionAndUserRow, error)
  • func (s *Service) DeleteSession(ctx, sessionID pgtype.UUID) error
  • func (s *Service) DeleteExpiredSessions(ctx) error

Constants: sessionTTL = 7 * 24h; dummyBcryptHash (pre-computed bcrypt hash used on the user-not-found path).

Posts (posts.go)

  • func (s *Service) GetRecentPosts(ctx, interval uint16, limit, offset int) ([]sql.Post, error) - interval is days (default 7).
  • func (s *Service) GetPostBySlug(ctx, slug string) (sql.GetPostBySlugRow, error) - validates the slug (ErrInvalidSlug), published-only.
  • func (s *Service) GetPostBySlugAny(ctx, slug string) (sql.GetPostBySlugAnyRow, error) - any non-deleted status.
  • func (s *Service) CreatePost(ctx, params sql.CreatePostParams, authorID pgtype.UUID, categoryIDs, mediaIDs []pgtype.UUID) (pgtype.UUID, error) - transactional: insert post, link owner, validate+link categories/media.
  • func (s *Service) UpdatePost(ctx, params sql.UpdatePostParams, ownerID pgtype.UUID, categoryIDs, mediaIDs []pgtype.UUID, requireOwnership bool) error - optional ownership check; nil category/media slices leave links untouched, empty slices clear them.
  • func (s *Service) ArchivePost(ctx, postID pgtype.UUID) error
  • func (s *Service) DeletePost(ctx, postID, userID pgtype.UUID, requireOwnership bool) error - soft delete.

Users (users.go)

  • func (s *Service) ListUsers(ctx, requesterID pgtype.UUID, limit, offset int) ([]sql.ListUsersRow, error)
  • func (s *Service) ListPendingUsers(ctx, limit, offset int) ([]sql.ListPendingUsersRow, error)
  • func (s *Service) DeleteUser(ctx, userID pgtype.UUID) error - transactional soft delete; last-admin guard; clears the user's sessions.
  • func (s *Service) UpdateUserStatus(ctx, params sql.UpdateUserStatusParams) error - deleted status delegates to DeleteUser; deactivation triggers the last-admin guard and session purge.
  • func (s *Service) guardLastAdmin(ctx, q *sql.Queries, userID pgtype.UUID) error - returns ErrLastAdmin when removing the last active admin.

Categories (categories.go) & Media (media.go)

  • func (s *Service) ListCategories(ctx, limit, offset int) ([]sql.Category, error)
  • func (s *Service) AddCategory(ctx, params sql.AddCategoryParams) error
  • func (s *Service) UploadMedia(ctx, params sql.UploadMediaParams) (pgtype.UUID, error)

Auth tokens (tokens.go)

Password-reset and email-verification tokens. Both kinds are stored hashed: newAuthToken() returns 32 random bytes hex-encoded (what the user receives) plus its SHA-256 digest (what the row stores), and lookups hash the incoming token before querying. A database leak therefore yields no usable links.

  • func (s *Service) RequestPasswordReset(ctx, email string) (token, username string, ok bool, err error) - ok is false when no active account matches. Callers must respond identically either way. Issuing a new link invalidates the previous one (one outstanding reset per account).
  • func (s *Service) ConfirmPasswordReset(ctx, rawToken, newPassword string) error - transactional: set the password, burn the account's reset tokens, and delete every session. ErrInvalidToken covers both unknown and expired.
  • func (s *Service) ResolveUnverifiedUser(ctx, email string) (userID pgtype.UUID, username string, ok bool, err error) - ok is false for unknown, already-verified, suspended, and deleted accounts alike, which is what keeps the resend endpoint enumeration-safe.
  • func (s *Service) CreateEmailVerification(ctx, userID pgtype.UUID) (string, error) - replaces any outstanding token.
  • func (s *Service) VerifyEmail(ctx, rawToken string) (sql.MarkEmailVerifiedRow, error) - readers become active; authors are marked verified but stay pending for admin approval (the SQL encodes that rule).
  • func (s *Service) DeleteExpiredAuthTokens(ctx) error - both kinds; called from the background cleanup sweep.

TTLs: passwordResetTTL = 1h, emailVerificationTTL = 24h.

Profiles (profile.go)

  • func (s *Service) GetMe(ctx, userID pgtype.UUID) (sql.GetMeRow, error) - profile plus counters (followers, following, unread notifications/messages).
  • func (s *Service) GetUserByID(ctx, userID pgtype.UUID) (sql.User, error)
  • func (s *Service) GetPublicProfile(ctx, username string) (sql.GetPublicUserByUsernameRow, error) - ErrUserNotFound when absent.
  • func (s *Service) ListPostsByAuthor(ctx, username string, limit, offset int) ([]sql.ListPostsByAuthorRow, error) - published only.
  • func (s *Service) ListMyPosts(ctx, userID pgtype.UUID, statusFilter string, limit, offset int) ([]sql.ListMyPostsRow, error) - the owner's own posts, drafts included; empty statusFilter means all.
  • type UpdateProfileInput struct and func (s *Service) UpdateProfile(ctx, userID pgtype.UUID, in UpdateProfileInput) error.
  • func (s *Service) ChangePassword(ctx, userID pgtype.UUID, currentPassword, newPassword string) (pgtype.UUID, pgtype.Timestamptz, error) - verifies the current password, then in one transaction stores the new hash, kills outstanding reset tokens, revokes every session on all devices, and issues a fresh session for the caller (returned as the new session ID and expiry). ErrInvalidCredentials when the current password is wrong.

Social (social.go)

Likes and follows. Both are idempotent — the bool return reports whether the call actually changed anything, so a double-tap is a no-op rather than an error (and never re-notifies).

  • func (s *Service) LikePost(ctx, postID, likerID pgtype.UUID, likerUsername, postSlug, postTitle string) (bool, error) - notifies the post owner on a new like.
  • func (s *Service) UnlikePost(ctx, postID, userID pgtype.UUID) (bool, error)
  • func (s *Service) PostLikeInfo(ctx, postID, viewerID pgtype.UUID) (int64, bool, error) - like count, plus whether the viewer liked it (only when viewerID is valid).
  • func (s *Service) Follow(ctx, followerID pgtype.UUID, followerUsername string, followeeID pgtype.UUID) (bool, error) - ErrSelfAction when the two IDs match; notifies the followee on a new follow.
  • func (s *Service) Unfollow(ctx, followerID, followeeID pgtype.UUID) (bool, error)
  • func (s *Service) IsFollowing(ctx, followerID, followeeID pgtype.UUID) (bool, error)
  • func (s *Service) ListFollowers(ctx, userID pgtype.UUID, limit, offset int) ([]sql.ListFollowersRow, error)
  • func (s *Service) ListFollowing(ctx, userID pgtype.UUID, limit, offset int) ([]sql.ListFollowingRow, error)

Comments (comments.go)

  • type CreateCommentInput struct { PostID, AuthorID, ParentID pgtype.UUID; PostSlug, PostTitle, AuthorUsername, Body string } - the handler resolves the post (published-only) before calling, so slug and title are already at hand for the notification payload. A zero ParentID means a top-level comment.
  • func (s *Service) CreateComment(ctx, in CreateCommentInput) (sql.Comment, error) - inserts and notifies the post owner (type comment) and, for a reply, the parent comment's author (type reply). ErrInvalidParentComment when the parent belongs to a different post.
  • func (s *Service) ListPostComments(ctx, postID pgtype.UUID, limit, offset int) ([]sql.ListPostCommentsRow, error) - visible comments only.
  • func (s *Service) UpdateComment(ctx, commentID, editorID pgtype.UUID, body string) error - author-only; ErrUnauthorizedOperation otherwise.
  • func (s *Service) DeleteComment(ctx, commentID, requesterID pgtype.UUID, isAdmin bool) error - soft delete. Admins may delete any comment; everyone else only their own.
  • func (s *Service) HideComment(ctx, commentID pgtype.UUID) error - the admin moderation action: the comment leaves public lists but is retained and recoverable, unlike a delete.

Status transitions use the comment_status_enum (visiblehidden or deleted); a missing or already-deleted comment returns ErrCommentNotFound rather than leaking that it once existed.

Notifications (notifications.go)

Every notification is persisted first, pushed secondpublish is best-effort, so a client that missed the live event still finds the row via GET /notifications.

  • func (s *Service) ListNotifications(ctx, userID pgtype.UUID, limit, offset int) ([]sql.ListNotificationsRow, error)
  • func (s *Service) CountUnreadNotifications(ctx, userID pgtype.UUID) (int64, error)
  • func (s *Service) MarkNotificationRead(ctx, notificationID, userID pgtype.UUID) (bool, error) - scoped by userID, so one user cannot mark another's notification read.
  • func (s *Service) MarkAllNotificationsRead(ctx, userID pgtype.UUID) error

Emitters (all fire-and-forget; failures are logged, never returned):

  • NotifyLike(ctx, actorID, actorUsername, postID, postSlug, postTitle)
  • NotifyFollow(ctx, followeeID, actorID, actorUsername)
  • NotifyFollowersOfNewPost(ctx, postID) - fans out to every follower when a post first becomes published. The owner is resolved from the post rather than taken from the caller, so an admin publishing someone else's draft still notifies the author's followers, attributed to the author.
  • NotifyAdminsOfPendingUser(ctx, newUserID, newUsername)
  • NotifyAccountActivated(ctx, userID, adminID)

Unexported: createAndPushNotification(...) (insert + publish) and notificationEvent, the SSE payload — it mirrors the list endpoint's fields so the frontend renders pushed and fetched notifications with the same code.

Messages (messages.go)

  • func (s *Service) SendMessage(ctx, senderID pgtype.UUID, senderUsername string, recipientID pgtype.UUID, body string) (sql.Message, error) - stores the message and pushes a message event to the recipient's live subscriptions. Messaging yourself is rejected (the schema enforces it too).
  • func (s *Service) ListConversations(ctx, userID pgtype.UUID, limit, offset int) ([]sql.ListConversationsRow, error) - conversations are derived from the message rows; there is no conversation table.
  • func (s *Service) ListMessagesWithUser(ctx, userID, peerID pgtype.UUID, limit, offset int) ([]sql.Message, error)
  • func (s *Service) MarkMessagesRead(ctx, userID, peerID pgtype.UUID) (int64, error) - returns how many rows were marked.
  • func (s *Service) CountUnreadMessages(ctx, userID pgtype.UUID) (int64, error)

Middleware Package (internal/middleware)

Location: internal/middleware/

CORS

  • type CORSConfig struct { AllowedOrigins, AllowedMethods, AllowedHeaders []string; AllowCredentials bool }
  • func LoadCORSConfigFromEnv() (CORSConfig, error) - requires CORS_ALLOWED_ORIGINS; rejects wildcard origins when credentials are allowed.
  • func CORSMiddleware(config CORSConfig) gin.HandlerFunc - allowlist check; rejects unknown-origin preflights with 403.

Request lifecycle & security

  • func RequestIDMiddleware() gin.HandlerFunc - X-Request-Id echo + context injection.
  • func AccessLogMiddleware() gin.HandlerFunc
  • func MaxBodyBytesMiddleware(maxBytes int64, exemptFullPaths ...string) gin.HandlerFunc - caps body size for POST/PUT/PATCH/DELETE; exempts the given Gin route patterns (e.g. /media).
  • func SecurityHeadersMiddleware() gin.HandlerFunc
  • func CSRFMiddleware() gin.HandlerFunc - double-submit; skips safe methods and Bearer auth; constant-time token compare.

Auth / role guards

  • func RequireAuthMiddleware(svc *service.Service) gin.HandlerFunc - validates session, requires active, sets user_id/user_role/user_status/ session_id/username in context. Any active account passes, including readers.
  • func OptionalAuthMiddleware(svc *service.Service) gin.HandlerFunc - the same context keys, but never rejects: a missing, malformed, expired, or non-active session simply falls through unauthenticated. It backs the personalized public endpoints (GET /posts/:slugliked_by_me, GET /users/:userfollowing).
  • func RequireAuthorMiddleware() gin.HandlerFunc - allows author or admin.
  • func RequireAdminMiddleware() gin.HandlerFunc - requires admin.

Rate limiting

type Limiter interface {
    Allow(ctx context.Context, key string) (int, time.Duration, bool, error)
    Limit() int
}
  • type RateLimiter struct{...} - in-memory fixed-window (mutex + map).
    • func NewRateLimiter(spec utils.RateLimitSpec) *RateLimiter
    • func (l *RateLimiter) Allow(...), func (l *RateLimiter) Limit() int
  • type RedisRateLimiter struct{...} - Redis Lua INCR+PEXPIRE fixed-window.
    • func NewRedisRateLimiter(client *redis.Client, spec utils.RateLimitSpec, prefix string) *RedisRateLimiter
    • func (r *RedisRateLimiter) Allow(...), func (r *RedisRateLimiter) Limit() int
  • func RateLimitMiddleware(limiter Limiter) gin.HandlerFunc - sets X-RateLimit-* headers, returns 429 + Retry-After when exceeded, 503 on limiter error.
  • Spec loaders (all return (utils.RateLimitSpec, error)): LoadRateLimitSpecFromEnv(envName, defaultSpec string), LoadLoginRateLimitSpec, LoadRegisterRateLimitSpec, LoadMediaRateLimitSpec, LoadAdminRateLimitSpec, LoadWriteRateLimitSpec. Defaults: login 10/1m, register 10/1m, media 30/1m, admin 60/1m, write 60/1m.

Realtime Package (internal/realtime)

Location: internal/realtime/hub.go

A minimal in-process pub/sub hub backing the SSE endpoint. Events are addressed to a single user, and one user may hold several subscriptions at once (multiple tabs).

Note

This is deliberately not Redis-backed. The app runs as a single instance and every Publish call site is best-effort — a missed live event still exists as a notification row, so the client re-syncs from the REST endpoints on reconnect. Running multiple instances would mean users only receive events raised by the instance they happen to be connected to.

// Event is one server-sent event: Name maps to the SSE "event:" field and
// Data is the pre-marshalled JSON payload for the "data:" field.
type Event struct {
    Name string
    Data []byte
}

type Hub struct{ ... }

func NewHub() *Hub
func (h *Hub) Subscribe(userID pgtype.UUID) (<-chan Event, func())
func (h *Hub) Publish(userID pgtype.UUID, event string, payload any)
  • Subscribe returns a receive channel and a cancel function. The cancel function must be called when the connection ends — it removes the subscription and closes the channel. The handler defers it.
  • The channel is buffered (16). Publish never blocks: a subscriber whose buffer is full simply misses the event.
  • Publish is a no-op for an invalid userID, and marshals the payload once for all of that user's subscriptions.

Hub satisfies service.Notifier, which is how the service layer pushes without importing the realtime package.


Mail Package (internal/mail)

Location: internal/mail/mail.go

Transactional outbound email (password reset, email verification) over SMTP.

type Mailer struct{ ... }

func LoadFromEnv() (*Mailer, error)
func (m *Mailer) Enabled() bool
func (m *Mailer) Send(to, subject, body string) error
  • LoadFromEnv reads SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, and SMTP_FROM. An empty SMTP_HOST returns (nil, nil) — a disabled mailer, not an error. When SMTP_HOST is set, SMTP_FROM is required and the port must be valid.
  • Every method is nil-safe: Enabled() reports false on a nil receiver, and Send logs the recipient and subject (never the body, which may carry a token) and returns nil.
  • Port 465 uses implicit TLS (sendImplicitTLS); everything else attempts STARTTLS (sendStartTLS).

Important

Leaving SMTP unconfigured changes application behavior, not just delivery: reset and verification links are written to the server log, and readers registering via POST /register are activated immediately instead of being asked to verify. That is intended for development — see DEVELOPMENT.md. Always configure SMTP in production.


SQL Package (internal/sql)

Location: internal/sql/ (generated by sqlc from schema.sql + query.sql; edit the SQL, not the .go files).

Constructor & transactions

  • func New(db DBTX) *Queries
  • func (q *Queries) WithTx(tx pgx.Tx) *Queries

Models (models.go)

  • type User struct - UserID pgtype.UUID, Username, Email string, UserRole UserRoleEnum, UserStatus UserStatusEnum, PasswordHash string, PhoneNumber pgtype.Text, DisplayName, Bio pgtype.Text, AvatarMediaID pgtype.UUID, EmailVerifiedAt, CreatedAt, LastLoginAt, DeletedAt pgtype.Timestamptz.
  • type Post struct - PostID pgtype.UUID, Title, Slug, MdContent string, Status PostStatusEnum, PublishedAt, UpdatedAt, ArchivedAt, DeletedAt pgtype.Timestamptz.
  • type Medium struct - MediaID, UserID pgtype.UUID, StorageKey string, MimeType pgtype.Text, CreatedAt pgtype.Timestamptz.
  • type Category struct - CategoryID pgtype.UUID, Name, Slug string.
  • type Session struct - SessionID, UserID pgtype.UUID, CreatedAt, ExpiresAt pgtype.Timestamptz.
  • type Comment struct - CommentID, PostID, UserID, ParentCommentID pgtype.UUID (null = top-level), Body string, Status CommentStatusEnum, IsAi bool, CreatedAt, EditedAt pgtype.Timestamptz.
  • type Like struct - UserID, PostID pgtype.UUID, CreatedAt pgtype.Timestamptz (composite key, which is what makes liking idempotent).
  • type Follow struct - FollowerID, FolloweeID pgtype.UUID, CreatedAt pgtype.Timestamptz.
  • type Notification struct - NotificationID, UserID (recipient), ActorID, PostID, CommentID pgtype.UUID, Type NotificationTypeEnum, ReadAt, CreatedAt pgtype.Timestamptz.
  • type Message struct - MessageID, SenderID, RecipientID pgtype.UUID, Body string, CreatedAt, ReadAt pgtype.Timestamptz. Conversations are derived from these rows; there is no conversation table.
  • type PasswordResetToken struct / type EmailVerificationToken struct - TokenHash string (the primary key — the raw token is never stored), UserID pgtype.UUID, ExpiresAt, CreatedAt pgtype.Timestamptz.
  • Join models: type UsersPost struct, type PostsMedium struct, type PostsCategory struct.

Query functions (selected, exact signatures)

func (q *Queries) GetUser(ctx, userID pgtype.UUID) (User, error)
func (q *Queries) GetRecentPosts(ctx, arg GetRecentPostsParams) ([]Post, error)
func (q *Queries) GetPostBySlug(ctx, slug string) (GetPostBySlugRow, error)
func (q *Queries) GetPostBySlugAny(ctx, slug string) (GetPostBySlugAnyRow, error)
func (q *Queries) ListCategories(ctx, arg ListCategoriesParams) ([]Category, error)
func (q *Queries) Register(ctx, arg RegisterParams) (pgtype.UUID, error)
func (q *Queries) Login(ctx, email string) (LoginRow, error)
func (q *Queries) CreatePost(ctx, arg CreatePostParams) (pgtype.UUID, error)
func (q *Queries) UploadMedia(ctx, arg UploadMediaParams) (pgtype.UUID, error)
func (q *Queries) LinkPostUser(ctx, arg LinkPostUserParams) error
func (q *Queries) LinkPostCategories(ctx, arg LinkPostCategoriesParams) error
func (q *Queries) LinkPostMediaBulk(ctx, arg LinkPostMediaBulkParams) error
func (q *Queries) ClearPostCategories(ctx, postID pgtype.UUID) error
func (q *Queries) ClearPostMedia(ctx, postID pgtype.UUID) error
func (q *Queries) CountCategoriesByIDs(ctx, categoryIds []pgtype.UUID) (int64, error)
func (q *Queries) CountMediaByUser(ctx, arg CountMediaByUserParams) (int64, error)
func (q *Queries) UpdatePost(ctx, arg UpdatePostParams) (int64, error)        // execrows
func (q *Queries) VerifyPostOwnership(ctx, arg VerifyPostOwnershipParams) (bool, error)
func (q *Queries) ArchivePost(ctx, postID pgtype.UUID) (int64, error)         // execrows
func (q *Queries) DeletePost(ctx, postID pgtype.UUID) error                   // soft delete
func (q *Queries) ListUsers(ctx, arg ListUsersParams) ([]ListUsersRow, error)
func (q *Queries) ListPendingUsers(ctx, arg ListPendingUsersParams) ([]ListPendingUsersRow, error)
func (q *Queries) CountAdmins(ctx) (int64, error)
func (q *Queries) DeleteUser(ctx, userID pgtype.UUID) error                   // soft delete
func (q *Queries) UpdateUserStatus(ctx, arg UpdateUserStatusParams) error
func (q *Queries) AddCategory(ctx, arg AddCategoryParams) error
func (q *Queries) AddSession(ctx, arg AddSessionParams) error
func (q *Queries) UpdateLastLogin(ctx, userID pgtype.UUID) error
func (q *Queries) DeleteSession(ctx, sessionID pgtype.UUID) error
func (q *Queries) GetSessionAndUser(ctx, sessionID pgtype.UUID) (GetSessionAndUserRow, error)
func (q *Queries) DeleteAllUserSessions(ctx, userID pgtype.UUID) error
func (q *Queries) DeleteExpiredSessions(ctx) error

The profile / social / messaging layer adds (81 query functions in total):

// Profiles & account
func (q *Queries) GetMe(ctx, userID pgtype.UUID) (GetMeRow, error)
func (q *Queries) GetPublicUserByUsername(ctx, username string) (GetPublicUserByUsernameRow, error)
func (q *Queries) UpdateUserProfile(ctx, arg UpdateUserProfileParams) (int64, error)   // execrows
func (q *Queries) UpdateUserPassword(ctx, arg UpdateUserPasswordParams) error
func (q *Queries) ListPostsByAuthor(ctx, arg ListPostsByAuthorParams) ([]ListPostsByAuthorRow, error)
func (q *Queries) ListMyPosts(ctx, arg ListMyPostsParams) ([]ListMyPostsRow, error)
func (q *Queries) SearchPosts(ctx, arg SearchPostsParams) ([]SearchPostsRow, error)

// Auth tokens (keyed by SHA-256 hash, never the raw token)
func (q *Queries) CreatePasswordResetToken(ctx, arg CreatePasswordResetTokenParams) error
func (q *Queries) GetPasswordResetToken(ctx, tokenHash string) (GetPasswordResetTokenRow, error)
func (q *Queries) DeletePasswordResetTokensForUser(ctx, userID pgtype.UUID) error
func (q *Queries) CreateEmailVerificationToken(ctx, arg CreateEmailVerificationTokenParams) error
func (q *Queries) GetEmailVerificationToken(ctx, tokenHash string) (GetEmailVerificationTokenRow, error)
func (q *Queries) DeleteEmailVerificationTokensForUser(ctx, userID pgtype.UUID) error
func (q *Queries) MarkEmailVerified(ctx, userID pgtype.UUID) (MarkEmailVerifiedRow, error)
func (q *Queries) DeleteExpiredPasswordResetTokens(ctx) error
func (q *Queries) DeleteExpiredEmailVerificationTokens(ctx) error

// Comments
func (q *Queries) CreateComment(ctx, arg CreateCommentParams) (Comment, error)
func (q *Queries) GetComment(ctx, commentID pgtype.UUID) (GetCommentRow, error)
func (q *Queries) ListPostComments(ctx, arg ListPostCommentsParams) ([]ListPostCommentsRow, error)
func (q *Queries) UpdateCommentBody(ctx, arg UpdateCommentBodyParams) (int64, error)   // execrows
func (q *Queries) SetCommentStatus(ctx, arg SetCommentStatusParams) (int64, error)     // execrows

// Likes & follows — all execrows, which is what makes them idempotent
func (q *Queries) LikePost(ctx, arg LikePostParams) (int64, error)
func (q *Queries) UnlikePost(ctx, arg UnlikePostParams) (int64, error)
func (q *Queries) CountPostLikes(ctx, postID pgtype.UUID) (int64, error)
func (q *Queries) HasUserLikedPost(ctx, arg HasUserLikedPostParams) (bool, error)
func (q *Queries) FollowUser(ctx, arg FollowUserParams) (int64, error)
func (q *Queries) UnfollowUser(ctx, arg UnfollowUserParams) (int64, error)
func (q *Queries) IsFollowing(ctx, arg IsFollowingParams) (bool, error)
func (q *Queries) ListFollowers(ctx, arg ListFollowersParams) ([]ListFollowersRow, error)
func (q *Queries) ListFollowing(ctx, arg ListFollowingParams) ([]ListFollowingRow, error)

// Notifications — the Notify* queries insert and return recipients in one round trip
func (q *Queries) CreateNotification(ctx, arg CreateNotificationParams) (Notification, error)
func (q *Queries) ListNotifications(ctx, arg ListNotificationsParams) ([]ListNotificationsRow, error)
func (q *Queries) CountUnreadNotifications(ctx, userID pgtype.UUID) (int64, error)
func (q *Queries) MarkNotificationRead(ctx, arg MarkNotificationReadParams) (int64, error)
func (q *Queries) MarkAllNotificationsRead(ctx, userID pgtype.UUID) error
func (q *Queries) NotifyFollowersOfPost(ctx, arg NotifyFollowersOfPostParams) ([]NotifyFollowersOfPostRow, error)
func (q *Queries) NotifyAdminsOfPendingUser(ctx, actorID pgtype.UUID) ([]NotifyAdminsOfPendingUserRow, error)

// Messages
func (q *Queries) CreateMessage(ctx, arg CreateMessageParams) (Message, error)
func (q *Queries) ListConversations(ctx, arg ListConversationsParams) ([]ListConversationsRow, error)
func (q *Queries) ListMessagesWithUser(ctx, arg ListMessagesWithUserParams) ([]Message, error)
func (q *Queries) MarkMessagesRead(ctx, arg MarkMessagesReadParams) (int64, error)     // execrows
func (q *Queries) CountUnreadMessages(ctx, recipientID pgtype.UUID) (int64, error)

Param/row structs include RegisterParams, LoginRow, CreatePostParams, UpdatePostParams, UploadMediaParams, AddCategoryParams, AddSessionParams, UpdateUserStatusParams, VerifyPostOwnershipParams, CountMediaByUserParams, GetRecentPostsParams, ListCategoriesParams, ListUsersParams/ListUsersRow, ListPendingUsersParams/ListPendingUsersRow, GetPostBySlugRow, GetPostBySlugAnyRow, GetSessionAndUserRow, LinkPostUserParams, LinkPostCategoriesParams, LinkPostMediaBulkParams.


Utils Package (internal/utils)

Location: internal/utils/

Request ID & logging (request_id.go)

  • func WithRequestID(ctx context.Context, requestID string) context.Context
  • func RequestIDFromContext(ctx context.Context) string
  • type RequestIDHandler struct and func NewRequestIDHandler(handler slog.Handler) slog.Handler - a slog.Handler wrapper that adds a request_id attribute to records that carry one in their context.

Config parsing (config.go)

  • type RateLimitSpec struct { Limit int; Window time.Duration }
  • func ParseRateLimitSpec(spec string) (RateLimitSpec, error) - parses count/duration (e.g. 10/1m).
  • func ParseCSV(value string) []string - trims and drops empty entries.

Security (security.go)

  • const CSRFTokenCookieName = "csrf_token"
  • const CSRFTokenHeaderName = "X-CSRF-Token"
  • func GenerateCSRFToken() (string, error) - 32 random bytes, base64url (raw) encoded.

Slugs (utils.go)

  • func GenerateSlug(title string) string
  • func IsValidSlug(s string) bool - true when s is non-empty and already in canonical slug form.

Migrate Package (internal/migrate)

Location: internal/migrate/

  • func Run(ctx context.Context, db *pgxpool.Pool) error - acquires one connection, takes a pg_advisory_lock, ensures schema_migrations exists, and applies unapplied embedded migrations in filename order. Files containing -- +no-transaction run outside a transaction; all others run inside one.

Embedded files, in apply order:

File What it adds
000_initial_schema.sql Users, posts, categories, media, sessions, join tables.
001_add_deleted_status.sql deleted on the status enums.
002_fix_post_automation_trigger.sql Post automation trigger fix.
003_streamline_post_trigger.sql Simplified post trigger.
004_add_reader_role.sql reader on user_role_enum — self-service accounts that can comment, like, follow, and message but cannot author posts. Carries -- +no-transaction, since ALTER TYPE … ADD VALUE cannot be used inside the transaction that adds it.
005_profiles_and_auth_tokens.sql Profile columns on users (display_name, bio, avatar_media_id, email_verified_at) plus the password_reset_tokens and email_verification_tokens tables.
006_social.sql comments, likes, follows, notifications, and the comment_status_enum / notification_type_enum types.
007_messages.sql messages. Conversations are derived from these rows.
008_post_search.sql Full-text search over posts, using the turkish text-search configuration.

Types & Enums

Defined in internal/sql/models.go (sqlc) and mirrored in the DB schema:

type UserRoleEnum string         // "admin", "author", "reader"
type UserStatusEnum string       // "pending", "active", "suspended", "deleted"
type PostStatusEnum string       // "draft", "published", "archived", "deleted"
type AuthorRoleEnum string       // "owner", "contributor"
type CommentStatusEnum string    // "visible", "hidden", "deleted"
type NotificationTypeEnum string // "comment", "reply", "like", "follow",
                                 // "new_post", "new_pending_user",
                                 // "account_activated"

Constants follow the EnumValue convention, e.g. sql.UserRoleEnumAdmin, sql.UserRoleEnumAuthor, sql.UserRoleEnumReader, sql.UserStatusEnumPending/Active/Suspended/Deleted, sql.PostStatusEnumDraft/Published/Archived/Deleted, sql.AuthorRoleEnumOwner/Contributor, sql.CommentStatusEnumVisible/Hidden/Deleted, sql.NotificationTypeEnumComment/Reply/Like/Follow/NewPost/ NewPendingUser/AccountActivated.

Each enum also has a NullXxx wrapper (sql.NullUserRoleEnum and friends) for nullable columns.

Role capabilities

Reader Author Admin
Comment, like, follow, message
Upload media (avatars)
Create / update / delete own posts
Moderate comments, manage users & categories
Activation path email verification admin approval bootstrap / admin

Last Updated: 2026-08-17

Generated from CODE_DOCUMENTATION.md on 2026-08-17 23:37 CEST. Edit the Markdown source and re-run task docs to update.