A secure, full-stack social media management platform that enables clients, managers, and agencies to collaborate without sharing credentials.
Arctiv solves a fundamental trust problem in social media management: clients need managers to operate their accounts, but handing over passwords is a security risk. Arctiv provides a structured access-grant system where clients retain ownership, managers get scoped permissions, and agencies can oversee teams — all tracked in a persistent database.
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| Database | PostgreSQL via Neon (serverless) |
| ORM / Query | @neondatabase/serverless tagged-template SQL |
| Auth | bcryptjs (password hashing) + jose (JWT, HS256) |
| Validation | Zod |
| UI | shadcn/ui + Tailwind CSS v4 |
| Session | HTTP-only cookies (7-day JWT, server-side revocation) |
| Role | Description |
|---|---|
client |
Owns social media accounts. Grants/revokes access to managers or agencies. Reviews and approves content drafts. |
manager |
Hired by clients. Creates content drafts for assigned accounts. Participates in the Managers Hub feed. |
agency |
Manages teams of managers. Has a company profile. Appears in the hub alongside managers. |
arctiv/
├── app/
│ ├── page.tsx # Public landing page
│ ├── login/page.tsx # Login page (all roles)
│ ├── register/page.tsx # Registration page (role selector)
│ ├── dashboard/
│ │ ├── page.tsx # Redirect → role-specific dashboard
│ │ ├── client/page.tsx # Client dashboard
│ │ ├── manager/page.tsx # Manager dashboard
│ │ └── agency/page.tsx # Agency dashboard
│ └── api/
│ ├── auth/
│ │ ├── register/route.ts # POST /api/auth/register
│ │ ├── login/route.ts # POST /api/auth/login
│ │ ├── logout/route.ts # POST /api/auth/logout
│ │ └── me/route.ts # GET /api/auth/me
│ ├── users/
│ │ └── profile/route.ts # GET/PATCH /api/users/profile
│ ├── social-accounts/
│ │ ├── route.ts # GET/POST /api/social-accounts
│ │ └── [id]/route.ts # DELETE /api/social-accounts/:id
│ ├── access-grants/
│ │ ├── route.ts # GET/POST /api/access-grants
│ │ └── [id]/route.ts # DELETE /api/access-grants/:id
│ ├── relationships/
│ │ ├── route.ts # GET/POST /api/relationships
│ │ └── [id]/route.ts # PATCH/DELETE /api/relationships/:id
│ ├── content-drafts/
│ │ ├── route.ts # GET/POST /api/content-drafts
│ │ └── [id]/route.ts # GET/PATCH/DELETE /api/content-drafts/:id
│ ├── hub/
│ │ ├── posts/
│ │ │ ├── route.ts # GET/POST /api/hub/posts
│ │ │ └── [id]/
│ │ │ ├── route.ts # DELETE /api/hub/posts/:id
│ │ │ ├── like/route.ts # POST /api/hub/posts/:id/like
│ │ │ └── comments/route.ts # GET/POST /api/hub/posts/:id/comments
│ │ └── managers/route.ts # GET /api/hub/managers (public directory)
│ ├── messages/
│ │ ├── conversations/route.ts # GET/POST /api/messages/conversations
│ │ └── [conversationId]/route.ts # GET/POST /api/messages/:conversationId
│ └── notifications/route.ts # GET/PATCH /api/notifications
├── lib/
│ ├── db.ts # Neon SQL client singleton
│ ├── auth.ts # bcrypt helpers + JWT sign/verify + jti hashing
│ ├── session.ts # Cookie management + DB session validation
│ ├── validate.ts # All Zod schemas
│ └── api.ts # ok/err response helpers + central error handler
├── types/
│ └── index.ts # All TypeScript interfaces (DB rows, API shapes, JWT)
├── components/
│ └── logout-button.tsx # Client component — hard-navigates to clear cookie
├── middleware.ts # JWT-based route protection + role enforcement
└── scripts/
└── 001_schema.sql # Full database migration (idempotent)
All tables are created by scripts/001_schema.sql. The migration is idempotent (CREATE TABLE IF NOT EXISTS, DO $$ BEGIN ... EXCEPTION WHEN duplicate_object).
Core identity table. One row per account regardless of role.
| Column | Type | Notes |
|---|---|---|
id |
UUID PK | gen_random_uuid() |
email |
TEXT UNIQUE | Login identifier |
password_hash |
TEXT | bcrypt, 12 rounds |
role |
user_role ENUM |
client, manager, agency |
is_active |
BOOLEAN | Soft-disable accounts |
Server-side session store. Enables token revocation without waiting for JWT expiry.
| Column | Type | Notes |
|---|---|---|
token_hash |
TEXT UNIQUE | SHA-256 of JWT jti |
ip_address |
INET | First IP from x-forwarded-for |
expires_at |
TIMESTAMPTZ | 7 days from creation |
Extended public info for all roles. Single row per user.
Notable columns: specialties TEXT[], availability, agency_name, team_size (manager/agency specific fields colocated in one table).
Social media pages owned by clients (or agencies). Stores an encrypted OAuth access token field (access_token_enc) for future OAuth integration.
Platforms: instagram, facebook, twitter, linkedin, tiktok.
The core trust primitive. Maps a social_account → a granted_to user, with active/revoked status. Enforces UNIQUE(social_account_id, granted_to_id) so grants are not duplicated.
Tracks the hiring lifecycle between a client and a manager: pending → active → dismissed.
Normalized messaging schema. Conversations are N-party (supports group chats). Messages track is_read per row.
LinkedIn-style feed for managers and agencies. Likes are deduplicated with UNIQUE(post_id, user_id). likes_count is a denormalized counter updated on insert/delete of likes.
Content workflow table. A manager creates a draft for a client's social account. Status flow: draft → pending_approval → approved | rejected → published. Includes rejection_note for feedback.
In-app notification log. Typed via notification_type ENUM covering content lifecycle, access changes, relationship events, and messages.
| ENUM | Values |
|---|---|
user_role |
client, manager, agency |
social_platform |
instagram, facebook, twitter, linkedin, tiktok |
access_status |
active, revoked |
relationship_status |
pending, active, dismissed |
content_status |
draft, pending_approval, approved, rejected, published |
notification_type |
content_pending, content_approved, content_rejected, access_granted, access_revoked, hire_request, hire_accepted, dismissed, new_message, report_ready |
set_updated_at() trigger fires on BEFORE UPDATE for: users, profiles, social_accounts, client_manager_relationships, content_drafts, hub_posts.
- Register —
POST /api/auth/registervalidates input with Zod, hashes password with bcrypt (12 rounds), inserts intousers+profiles, signs a JWT, stores a session row, sets an HTTP-only cookie. - Login —
POST /api/auth/loginverifies password, issues a new JWT, stores a new session row, sets cookie. - Session validation — Every protected route calls
requireSession()which: reads the cookie → verifies JWT signature → looks uptoken_hashinsessionstable → confirms not expired. Both the JWT and the DB session must be valid. - Logout —
POST /api/auth/logoutdeletes the session row from the DB, then returns a303 redirectresponse withSet-Cookie: maxAge=0to expire the cookie at the browser level. TheLogoutButtoncomponent useswindow.location.href(notrouter.push) to perform a hard navigation so the browser processes theSet-Cookieheader before the middleware runs again.
{
"sub": "<user_id>",
"role": "client | manager | agency",
"jti": "<uuid>",
"iat": 1234567890,
"exp": 1235172690
}The jti is stored as SHA-256(jti) in the sessions table for revocation lookups.
middleware.ts runs on all /dashboard/*, /login, and /register routes:
- Unauthenticated requests to
/dashboard/*→ redirect to/login?next=<path> - Authenticated requests to
/loginor/register→ redirect to role-specific dashboard - Authenticated requests to the wrong role's dashboard → redirect to own dashboard
GET /dashboard(bare) → redirect to/dashboard/<role>
All endpoints return { success: true, data: ... } on success or { success: false, error: "...", details?: {...} } on failure.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/auth/register |
No | Create account. Body: email, password, role, full_name |
| POST | /api/auth/login |
No | Login. Body: email, password |
| POST | /api/auth/logout |
Yes | Delete session, expire cookie, redirect to /login |
| GET | /api/auth/me |
Yes | Returns current user + profile |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/users/profile |
Yes | Get own profile |
| PATCH | /api/users/profile |
Yes | Update profile fields |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/social-accounts |
Yes | List own social accounts |
| POST | /api/social-accounts |
Yes | Connect a new account |
| DELETE | /api/social-accounts/:id |
Yes | Remove account (owner only) |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/access-grants |
Yes | List grants (as grantor or grantee) |
| POST | /api/access-grants |
Yes | Grant access to a manager/agency |
| DELETE | /api/access-grants/:id |
Yes | Revoke a grant (grantor only) |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/relationships |
Yes | List hire relationships |
| POST | /api/relationships |
Yes | Hire a manager (client only) |
| PATCH | /api/relationships/:id |
Yes | Accept hire request (manager only) |
| DELETE | /api/relationships/:id |
Yes | Dismiss manager (client only) |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/content-drafts |
Yes | List drafts (manager sees own; client sees drafts for them) |
| POST | /api/content-drafts |
Yes | Create draft (manager only) |
| GET | /api/content-drafts/:id |
Yes | Get single draft |
| PATCH | /api/content-drafts/:id |
Yes | Update draft or submit for review |
| DELETE | /api/content-drafts/:id |
Yes | Delete draft (manager, own only) |
Content review (approve/reject) is done via PATCH /api/content-drafts/:id with { action: "approved" | "rejected", rejection_note? } — client role only.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/hub/managers |
Yes | Browse manager/agency profiles |
| GET | /api/hub/posts |
Yes | Feed of hub posts (paginated) |
| POST | /api/hub/posts |
Yes | Create a post (manager/agency only) |
| DELETE | /api/hub/posts/:id |
Yes | Delete own post |
| POST | /api/hub/posts/:id/like |
Yes | Toggle like on a post |
| GET | /api/hub/posts/:id/comments |
Yes | List comments |
| POST | /api/hub/posts/:id/comments |
Yes | Add a comment |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/messages/conversations |
Yes | List conversations |
| POST | /api/messages/conversations |
Yes | Start a new conversation |
| GET | /api/messages/:conversationId |
Yes | List messages in a conversation |
| POST | /api/messages/:conversationId |
Yes | Send a message |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/notifications |
Yes | List notifications (unread first) |
| PATCH | /api/notifications |
Yes | Mark as read ({ all: true } or { notification_ids: [...] }) |
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | Neon PostgreSQL connection string (set by Neon integration) |
JWT_SECRET |
Yes | Minimum 32-character secret for signing JWTs |
NODE_ENV |
Auto | Set to production by Vercel to enable secure cookies |
- Primary color: Deep navy (
oklch(0.26 0.07 255)) - Accent color: Ice blue (
oklch(0.70 0.14 225)) - Neutrals: Off-white background, slate borders, muted gray text
- Dark mode: Full dark theme via
.darkCSS class with matching token set - Typography: Geist Sans (body) + Geist Mono (code)
- Components: shadcn/ui component library throughout
- OAuth token encryption (
access_token_enc) column exists but encryption/decryption is not yet implemented — tokens are stored as plain text until an encryption key is introduced. - Real-time messaging (WebSockets / SSE) is not implemented; the messages API is polling-based.
- File/media uploads for content drafts and hub posts store URLs only — a Blob storage integration (e.g. Vercel Blob) would be needed for actual uploads.
- Email notifications (hire requests, content approvals) are not yet wired — the in-app
notificationstable is fully functional but no email delivery service is connected. - No rate limiting on auth endpoints yet.