Arsi India Info
Portfolio Build · Public GitHub Demo

Cloud Document Management System

A Dropbox/Drive-style secure business document vault — folders, versioned uploads, internal sharing, expiring external links, full-text-free metadata search, and a complete audit trail — built with React, PHP CodeIgniter 4, MySQL and AWS, with every document byte kept behind a private S3 bucket and backend-issued signed URLs.

Repository
secure-cloud-document-manager
Author
R.M. — Arsi India Info
Category
PHP · AWS S3 · File Security · REST API
License
MIT (code) + Trademark Notice (brand)

1. Executive Summary

Ownership This project, including its source code, documentation and this plan, is authored and published by Arsi India Info. It is released publicly under the MIT License for portfolio and learning purposes; the Arsi India Info name and logo remain protected brand assets.

The platform lets a business — modeled here as the fictional Meridian Consulting Group — store, organize, version and share its documents (client contracts, invoices, HR letters, compliance policies) the way Dropbox or Google Drive would, except every byte lives in a private S3 bucket that is never reachable directly. Every upload, download, preview and share is authorized first, then served through a short-lived, backend-generated signed URL. This is deliberately the centerpiece of the project: it proves the candidate understands that "storing files in S3" and "storing files in S3 securely" are two different skills.

10
MySQL Tables
12
Stored Procedures
31
API Endpoints
11
Screens
4
AWS Services

Scope is deliberately narrow where breadth would add no portfolio value: one storage backend (S3, no multi-cloud abstraction), metadata/name search only (no OCR or full-text content indexing), a stub virus-scan step (logs a result, does not integrate a real scanning engine), and synthetic seed documents for one fictional company — never real business documents, real credentials, or a publicly reachable bucket.

2. Why This Is a Top-Tier Portfolio Project

Document-upload demos are common; a document system that treats authorization as a first-class, per-file concern is not. This project is chosen because it forces decisions a reviewer will recognize as production-grade:

Depth it proves

  • Private-by-default object storage with backend-brokered, time-boxed signed URLs (§10)
  • Two-layer authorization: global role + per-resource ACL, with folder-to-document inheritance
  • Transactional, race-safe versioning and cascading soft-delete implemented in stored procedures (§8)
  • Asynchronous post-upload processing (thumbnailing, metadata extraction) decoupled from the request path via S3 events + Lambda
  • An immutable audit trail that answers "who touched this file, and when" for every sensitive action

What it is deliberately not

  • Not a real virus scanner or DLP product — the scan step is a documented stub
  • Not a full-text search engine — search matches name/description/tags, not file contents
  • Not a multi-tenant SaaS — one company, one workspace; the interesting complexity is per-file sharing, not billing/tenancy
  • Not an e-signature or workflow-approval tool

3. Technology Stack

Frontend

  • React 19 + TypeScript + Vite (matches the repo's existing scaffold)
  • React Query (server state, cache invalidation)
  • Tailwind CSS
  • React Hook Form + Zod (field-level validation)
  • Direct-to-S3 upload via the browser's Fetch API against a presigned PUT URL

Backend

  • PHP 8.2 + CodeIgniter 4.7 (matches the repo's existing composer.json)
  • MySQL 8 via MySQLi, accessed mostly through stored procedures (§8)
  • firebase/php-jwt for access/refresh tokens
  • aws/aws-sdk-php for S3 presigned URLs
  • zircote/swagger-php for OpenAPI annotations (§26)

AWS

  • S3 — one private bucket for documents (Block Public Access on, no public ACLs) + one public bucket for the built SPA
  • CloudFront — CDN in front of the SPA bucket only; never in front of the documents bucket
  • IAM — a least-privilege role for the API (put/get on one bucket prefix, no list, no delete) and a separate, tighter role for the async processing Lambda
  • Lambda — S3-event-triggered post-upload processing: thumbnail generation, basic metadata extraction, virus-scan stub

Quality & delivery tooling

  • PHPUnit 10 (already scaffolded by CodeIgniter 4) + CIUnitTestCase feature tests
  • PHP-CS-Fixer (PSR-12) + PHPStan level 6
  • Vitest + React Testing Library, Playwright smoke suite
  • Docker + docker-compose (API, MySQL, phpMyAdmin, LocalStack for S3/Lambda emulation)
  • GitHub Actions CI (lint → static analysis → test → build)

4. Repository & Folder Structure

Decision
Keep the repository's existing top-level shape — backend/ (CodeIgniter 4 appstarter), frontend/ (Vite React/TS), docs/, infrastructure/ — and build inside it rather than restructuring.
The repo is already scaffolded this way (CI4 installed via Composer, Vite React/TS installed via npm, MySQL configured in backend/.env). This plan fills in app/ and src/, it does not reorganize what's already there.
Why: a reviewer cloning the repo sees standard CodeIgniter 4 and standard Vite conventions immediately — no bespoke layout to learn before the interesting code starts.
secure-cloud-document-manager/
├── backend/                              # CodeIgniter 4 (already scaffolded)
│   ├── app/
│   │   ├── Config/                       # Routes.php, Filters.php, Database.php, Aws.php (new), Validation.php
│   │   ├── Controllers/                  # thin: validate via rule group, delegate to Services, return envelope
│   │   │   ├── AuthController.php
│   │   │   ├── UsersController.php
│   │   │   ├── FoldersController.php
│   │   │   ├── DocumentsController.php
│   │   │   ├── SharingController.php
│   │   │   ├── PublicShareController.php     # unauthenticated share-link landing endpoint
│   │   │   ├── AuditLogController.php
│   │   │   └── ProcessingCallbackController.php  # internal, HMAC-signed, called by the Lambda worker
│   │   ├── Services/                     # business rules + authorization; the only callers of Models/SPs
│   │   │   ├── AuthService.php
│   │   │   ├── FolderService.php
│   │   │   ├── DocumentService.php
│   │   │   ├── SharingService.php
│   │   │   └── AuditService.php
│   │   ├── Models/                       # thin wrappers around `CALL sp_xxx(...)` + simple query-builder reads
│   │   ├── Entities/                     # User, Folder, Document, DocumentVersion, ShareLink (typed CI4 Entities)
│   │   ├── Libraries/                    # S3Service (presign put/get), JwtService, HmacSignatureVerifier
│   │   ├── Filters/                      # JwtAuthFilter, RoleFilter, RateLimitFilter, InternalHmacFilter
│   │   ├── Exceptions/                   # ApiException base + typed subclasses carrying httpStatus + errorCode
│   │   └── Database/
│   │       ├── Migrations/               # one class per table, §7
│   │       ├── Seeds/                    # DemoSeeder — Meridian Consulting Group synthetic data
│   │       └── Procedures/                   # raw .sql files for every CREATE PROCEDURE, §8 — run by a migration
│   └── tests/
│       ├── unit/                         # Service-layer unit tests
│       └── api/                          # CIUnitTestCase + FeatureTestTrait, one file per controller
│
├── frontend/                             # React + TypeScript + Vite (already scaffolded)
│   └── src/
│       ├── app/                          # router, providers, layout shell
│       ├── features/
│       │   ├── auth/
│       │   ├── browser/                  # folder tree + document grid/list — the main workspace
│       │   ├── documents/                # detail/preview panel, versions tab, sharing tab, activity tab
│       │   ├── trash/
│       │   ├── audit/
│       │   └── users/
│       ├── components/                   # Breadcrumbs, UploadDropzone, FilePreview, DataTable, ConfirmDialog...
│       ├── lib/                          # apiClient, queryClient, direct-to-S3 uploader helper
│       └── types/
│
├── shared/                               # constants shared by both layers (permission enum, error codes)
├── infrastructure/                       # deployment-related, kept out of application code (already present)
│   ├── docker/                           # Dockerfile.api, Dockerfile.web
│   ├── docker-compose.yml
│   ├── aws/                              # Terraform/CDK: S3 buckets + policies, CloudFront, IAM roles, Lambda
│   └── github-actions/
│
├── docs/                                 # see §32 (already present, empty scaffold)
├── LICENSE                               # MIT (already present)
├── TRADEMARK.md                          # Arsi India Info name/logo notice — see §31
└── README.md                             # already present — expanded per §32

5. Coding Standards & Conventions

Separation of concerns

  • Controller — HTTP concerns only: run the CI4 Validation rule group, read the authenticated user off the request, call one Service method, map its result/exception to the response envelope.
  • Service — business rules, authorization checks (role + ACL), transaction boundaries. Framework-agnostic PHP classes, unit-testable without booting CodeIgniter's HTTP stack.
  • Model — the only layer that touches the database; for anything transactional or multi-table it calls a stored procedure (CALL sp_xxx(...)) rather than composing raw SQL, so the transaction/locking logic lives in one place (§8).
  • Entity — typed value objects (CI4 Entities) returned by Models; Controllers never see a raw associative array from the database.

Reusable building blocks

  • ApiResponseTrait — used by every Controller to build the success/error envelope (§13)
  • PaginationRequestDTO — page/limit/sort/search parsing reused by every list endpoint (§24)
  • S3Service::presignPut() / presignGet() — the only code path allowed to talk to the AWS SDK
  • Shared React components: Breadcrumbs, UploadDropzone, FilePreview, PermissionBadge, DataTable, ConfirmDialog used by every screen in §22

Validation

  • Every endpoint declares a named rule group in app/Config/Validation.php; the Controller calls $this->validateData() before touching a Service — no unvalidated input reaches business logic
  • Filenames are sanitized (path-traversal characters stripped, extension whitelisted against the allowed MIME set) before ever being used to build an S3 key
  • Business rules that need a DB lookup (e.g. "folder name must be unique among siblings") live in the stored procedure that performs the write, not in PHP-side pre-checks that can race
  • The frontend mirrors every constraint with Zod so invalid input never reaches the network call — see §23

Exception handling

  • Typed exceptions (DocumentNotFoundException, ForbiddenActionException, ShareLinkExpiredException...) extend a base ApiException carrying httpStatus and errorCode
  • A global exception handler (app/Config/Exceptions.php override) converts any thrown exception — typed, validation, or unexpected — into the standard envelope (§13)
  • Stored procedures signal business-rule failures via SIGNAL SQLSTATE '45000' with a structured message that the Model layer parses into the matching typed exception (§8)
  • Unexpected errors are logged with a request-correlation id and returned as 500 INTERNAL_ERROR with no stack trace leaked to the client

Security practices

  • Every write to the documents bucket goes through a presigned URL scoped to one exact object key with a 5-minute expiry — the API's IAM credentials never touch s3:ListBucket or s3:DeleteObject (§10)
  • CORS allow-list, per-user + per-IP rate limiting (RateLimitFilter, cache-backed), and security headers (CSP, X-Content-Type-Options, X-Frame-Options) applied via a global Filter
  • Passwords hashed with password_hash() (BCRYPT); JWT access tokens short-lived (15 min), refresh tokens rotated and stored hashed
  • The internal processing-callback route trusts only an HMAC signature computed with a secret shared with the Lambda worker — not a user JWT (§9.3)
  • All database access uses parameterized stored-procedure calls — no string-concatenated SQL anywhere in the codebase
  • Secrets (JWT secret, AWS keys, HMAC secret) come only from environment variables; backend/.env is git-ignored, only .env.example is committed

Logging & configuration

  • Structured logging via CI4's PSR-3 Logger, with a request-correlation id attached to every log line for a request and propagated into the async processing job's logs
  • No direct echo/var_dump debugging left in committed code — a CI lint step greps for it
  • Config (bucket names, region, token TTLs, rate limits) is centralized in app/Config/Aws.php and app/Config/Auth.php, sourced from .env via CodeIgniter's env() helper — no hard-coded environment values in business code
  • Per-environment .env.example documented in §32

6. Roles & Document/Folder Permissions

Authorization is two layers, matching how a real document-management product actually works — a global role for admin-type capabilities, and a per-resource ACL for "who can see this specific file."

6.1 Global roles

RoleManage usersView audit logAccess any folder by defaultTypical use
ADMINYesYesYesIT/records administrator
MANAGERNoNoOnly folders shared with themDepartment head
EMPLOYEENoNoOnly folders/files shared with themRegular staff member

6.2 Per-resource permission (sharing)

PermissionView/previewDownloadUpload new versionRename/moveShare with othersDelete
VIEWERYesYesNoNoNoNo
EDITORYesYesYesYesNoNo
OWNERYesYesYesYesYesYes (soft)

6.3 How access is resolved

  1. The uploader of a document (or creator of a folder) is granted OWNER automatically.
  2. A permission granted on a folder is inherited by every document inside it unless a more specific grant exists directly on the document (folder grant is the floor, not a ceiling).
  3. ADMIN bypasses ACL checks entirely (needed for the audit/records-management use case) — every such bypass is itself written to the audit log (§11) so the bypass is never invisible.
  4. Every Controller action that touches a document or folder calls DocumentService::authorize($user, $document, $requiredPermission) — there is exactly one code path that can grant access, so a missing check can't silently exist in one endpoint but not another.
Guardrail Every document/folder integration test includes a case where a second seeded user with no grant requests the resource — asserting 404 (not 403), so a non-participant can never learn that a document even exists.

7. Database Schema (MySQL)

7.1 Table inventory

TablePurposeKey relationships
usersAccounts, credentials, global role
refresh_tokensHashed refresh tokens for rotationFK → users
foldersNested folder tree (adjacency list)FK parent_folder_idfolders; FK created_byusers
documentsLogical file record (name, folder, tags, soft-delete)FK → folders, users
document_versionsOne row per uploaded version — the actual S3 object pointerFK → documents, users
document_permissionsInternal user-to-user sharing grants, folder or document scopedFK → folders/documents, users
share_linksExpiring external download links (token-based)FK → documents, users
document_processing_jobsAsync thumbnail/metadata pipeline status per versionFK → document_versions
audit_logsImmutable record of every sensitive actionFK → users (nullable, for system actions)
trash_retention_settingsSingle-row config: how many days a soft-deleted item is recoverable

7.2 Full DDL (abbreviated to essential columns)

CREATE TABLE users (
  id              BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name            VARCHAR(120)     NOT NULL,
  email           VARCHAR(190)     NOT NULL,
  password_hash   VARCHAR(255)     NOT NULL,
  role            ENUM('ADMIN','MANAGER','EMPLOYEE') NOT NULL DEFAULT 'EMPLOYEE',
  status          ENUM('ACTIVE','DISABLED') NOT NULL DEFAULT 'ACTIVE',
  created_at      DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at      DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted_at      DATETIME         NULL,
  UNIQUE KEY uq_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE folders (
  id                BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  parent_folder_id  BIGINT UNSIGNED NULL,
  name              VARCHAR(180)     NOT NULL,
  created_by        BIGINT UNSIGNED NOT NULL,
  created_at        DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at        DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted_at        DATETIME         NULL,
  CONSTRAINT fk_folders_parent FOREIGN KEY (parent_folder_id) REFERENCES folders(id) ON DELETE RESTRICT,
  CONSTRAINT fk_folders_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT,
  UNIQUE KEY uq_folder_sibling_name (parent_folder_id, name),
  KEY idx_folders_parent (parent_folder_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE documents (
  id              BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  folder_id       BIGINT UNSIGNED NOT NULL,
  name            VARCHAR(200)     NOT NULL,
  description     VARCHAR(500)     NULL,
  tags            VARCHAR(255)     NULL,               -- comma-separated, portfolio-scoped (no tag table)
  current_version INT UNSIGNED    NOT NULL DEFAULT 0,
  created_by      BIGINT UNSIGNED NOT NULL,
  created_at      DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at      DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted_at      DATETIME         NULL,
  CONSTRAINT fk_documents_folder FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE RESTRICT,
  CONSTRAINT fk_documents_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT,
  KEY idx_documents_folder (folder_id),
  FULLTEXT KEY ftx_documents_search (name, description, tags)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE document_versions (
  id                BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  document_id       BIGINT UNSIGNED NOT NULL,
  version_no        INT UNSIGNED    NOT NULL,
  s3_bucket         VARCHAR(120)     NOT NULL,
  s3_key            VARCHAR(400)     NOT NULL,
  mime_type         VARCHAR(150)     NOT NULL,
  size_bytes        BIGINT UNSIGNED NOT NULL,
  checksum_sha256   CHAR(64)         NOT NULL,
  thumbnail_s3_key  VARCHAR(400)     NULL,             -- filled in by the Lambda pipeline, §9.3
  is_current        TINYINT(1)       NOT NULL DEFAULT 0,
  uploaded_by       BIGINT UNSIGNED NOT NULL,
  uploaded_at       DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT fk_versions_document FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE RESTRICT,
  CONSTRAINT fk_versions_uploader FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE RESTRICT,
  UNIQUE KEY uq_document_version (document_id, version_no),
  KEY idx_versions_document_current (document_id, is_current)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE document_permissions (
  id             BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  folder_id      BIGINT UNSIGNED NULL,
  document_id    BIGINT UNSIGNED NULL,
  user_id        BIGINT UNSIGNED NOT NULL,
  permission     ENUM('VIEWER','EDITOR','OWNER') NOT NULL,
  granted_by     BIGINT UNSIGNED NOT NULL,
  granted_at     DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT ck_permission_target CHECK (
    (folder_id IS NOT NULL AND document_id IS NULL) OR (folder_id IS NULL AND document_id IS NOT NULL)
  ),
  CONSTRAINT fk_perm_folder FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE CASCADE,
  CONSTRAINT fk_perm_document FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE,
  CONSTRAINT fk_perm_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  UNIQUE KEY uq_perm_folder_user (folder_id, user_id),
  UNIQUE KEY uq_perm_document_user (document_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE share_links (
  id              BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  document_id     BIGINT UNSIGNED NOT NULL,
  token           CHAR(43)         NOT NULL,          -- URL-safe base64 of 32 random bytes
  permission      ENUM('VIEW','DOWNLOAD') NOT NULL DEFAULT 'DOWNLOAD',
  max_downloads   INT UNSIGNED    NULL,
  download_count  INT UNSIGNED    NOT NULL DEFAULT 0,
  expires_at      DATETIME         NOT NULL,
  revoked_at      DATETIME         NULL,
  created_by      BIGINT UNSIGNED NOT NULL,
  created_at      DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT fk_sharelink_document FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE,
  CONSTRAINT fk_sharelink_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT,
  UNIQUE KEY uq_sharelink_token (token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE audit_logs (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id       BIGINT UNSIGNED NULL,
  action        VARCHAR(60)      NOT NULL,
  entity_type   ENUM('DOCUMENT','FOLDER','USER','SHARE_LINK') NOT NULL,
  entity_id     BIGINT UNSIGNED NOT NULL,
  details       JSON            NULL,
  ip_address    VARCHAR(45)      NULL,
  created_at    DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT fk_audit_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
  KEY idx_audit_entity (entity_type, entity_id),
  KEY idx_audit_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

document_processing_jobs, refresh_tokens, and trash_retention_settings follow the same conventions (audit columns, InnoDB, utf8mb4) and are included in the migration set under app/Database/Migrations/ — omitted here for brevity since they add no new relational patterns beyond what's shown above.

7.3 Design notes

8. Stored Procedures

Every multi-table or race-sensitive write goes through a stored procedure — this is where transaction boundaries, row locking, and business-rule validation actually live, not scattered across PHP call sites. Every procedure follows the same shape: business-rule failures are raised as SIGNAL SQLSTATE '45000' with a machine-parseable status_code prefix in the message text; unexpected database errors are caught by a DECLARE ... HANDLER FOR SQLEXCEPTION that rolls back and re-signals, so the calling PHP code always gets one of a known set of outcomes.

8.1 Pattern A — validated insert with duplicate check (sp_folder_create)

DELIMITER $$
CREATE PROCEDURE sp_folder_create(
  IN  p_name VARCHAR(180),
  IN  p_parent_folder_id BIGINT UNSIGNED,
  IN  p_created_by BIGINT UNSIGNED,
  OUT p_folder_id BIGINT UNSIGNED,
  OUT p_status_code VARCHAR(30),
  OUT p_message VARCHAR(255)
)
BEGIN
  DECLARE v_parent_exists INT DEFAULT 0;
  DECLARE EXIT HANDLER FOR SQLEXCEPTION
  BEGIN
    ROLLBACK;
    SET p_status_code = 'INTERNAL_ERROR', p_message = 'Unexpected database error while creating folder.';
  END;

  START TRANSACTION;

  IF p_parent_folder_id IS NOT NULL THEN
    SELECT COUNT(*) INTO v_parent_exists FROM folders
      WHERE id = p_parent_folder_id AND deleted_at IS NULL FOR UPDATE;
    IF v_parent_exists = 0 THEN
      ROLLBACK;
      SET p_status_code = 'PARENT_NOT_FOUND', p_message = 'Parent folder does not exist or is deleted.';
      LEAVE sp_folder_create;
    END IF;
  END IF;

  IF EXISTS (
    SELECT 1 FROM folders
    WHERE name = p_name AND deleted_at IS NULL
      AND ((parent_folder_id IS NULL AND p_parent_folder_id IS NULL) OR parent_folder_id = p_parent_folder_id)
  ) THEN
    ROLLBACK;
    SET p_status_code = 'DUPLICATE_NAME', p_message = 'A folder with this name already exists here.';
    LEAVE sp_folder_create;
  END IF;

  INSERT INTO folders (name, parent_folder_id, created_by)
    VALUES (p_name, p_parent_folder_id, p_created_by);
  SET p_folder_id = LAST_INSERT_ID();

  INSERT INTO audit_logs (user_id, action, entity_type, entity_id, details)
    VALUES (p_created_by, 'FOLDER_CREATED', 'FOLDER', p_folder_id, JSON_OBJECT('name', p_name));

  COMMIT;
  SET p_status_code = 'OK', p_message = 'Folder created.';
END$$
DELIMITER ;

8.2 Pattern B — transactional versioning with row locking (sp_document_new_version)

DELIMITER $$
CREATE PROCEDURE sp_document_new_version(
  IN  p_document_id BIGINT UNSIGNED, IN p_s3_bucket VARCHAR(120), IN p_s3_key VARCHAR(400),
  IN  p_mime_type VARCHAR(150), IN p_size_bytes BIGINT UNSIGNED, IN p_checksum CHAR(64), IN p_uploaded_by BIGINT UNSIGNED,
  OUT p_version_id BIGINT UNSIGNED, OUT p_version_no INT UNSIGNED, OUT p_status_code VARCHAR(30), OUT p_message VARCHAR(255)
)
BEGIN
  DECLARE v_deleted_at DATETIME;
  DECLARE EXIT HANDLER FOR SQLEXCEPTION
  BEGIN ROLLBACK; SET p_status_code = 'INTERNAL_ERROR', p_message = 'Unexpected database error while adding version.'; END;

  START TRANSACTION;

  -- lock the parent row so two concurrent uploads can never both compute the same next version_no
  SELECT deleted_at INTO v_deleted_at FROM documents WHERE id = p_document_id FOR UPDATE;
  IF v_deleted_at IS NOT NULL THEN
    ROLLBACK; SET p_status_code = 'DOCUMENT_DELETED', p_message = 'Cannot version a deleted document.'; LEAVE sp_document_new_version;
  END IF;

  UPDATE document_versions SET is_current = 0 WHERE document_id = p_document_id AND is_current = 1;

  SELECT COALESCE(MAX(version_no), 0) + 1 INTO p_version_no FROM document_versions WHERE document_id = p_document_id;

  INSERT INTO document_versions (document_id, version_no, s3_bucket, s3_key, mime_type, size_bytes, checksum_sha256, is_current, uploaded_by)
    VALUES (p_document_id, p_version_no, p_s3_bucket, p_s3_key, p_mime_type, p_size_bytes, p_checksum, 1, p_uploaded_by);
  SET p_version_id = LAST_INSERT_ID();

  UPDATE documents SET current_version = p_version_no WHERE id = p_document_id;

  INSERT INTO document_processing_jobs (document_version_id, status) VALUES (p_version_id, 'PENDING');
  INSERT INTO audit_logs (user_id, action, entity_type, entity_id, details)
    VALUES (p_uploaded_by, 'DOCUMENT_VERSION_UPLOADED', 'DOCUMENT', p_document_id, JSON_OBJECT('versionNo', p_version_no));

  COMMIT;
  SET p_status_code = 'OK', p_message = 'Version uploaded.';
END$$
DELIMITER ;

8.3 Pattern C — validate-and-consume with branching outcomes (sp_share_link_consume)

DELIMITER $$
CREATE PROCEDURE sp_share_link_consume(
  IN  p_token CHAR(43),
  OUT p_document_id BIGINT UNSIGNED, OUT p_permission VARCHAR(10),
  OUT p_status_code VARCHAR(30), OUT p_message VARCHAR(255)
)
BEGIN
  DECLARE v_expires_at DATETIME; DECLARE v_revoked_at DATETIME;
  DECLARE v_max INT UNSIGNED; DECLARE v_count INT UNSIGNED; DECLARE v_found INT DEFAULT 0;
  DECLARE EXIT HANDLER FOR SQLEXCEPTION
  BEGIN ROLLBACK; SET p_status_code = 'INTERNAL_ERROR', p_message = 'Unexpected database error while resolving link.'; END;

  START TRANSACTION;
  SELECT COUNT(*) INTO v_found FROM share_links WHERE token = p_token FOR UPDATE;
  IF v_found = 0 THEN
    ROLLBACK; SET p_status_code = 'NOT_FOUND', p_message = 'This link is invalid.'; LEAVE sp_share_link_consume;
  END IF;

  SELECT document_id, permission, expires_at, revoked_at, max_downloads, download_count
    INTO p_document_id, p_permission, v_expires_at, v_revoked_at, v_max, v_count
    FROM share_links WHERE token = p_token;

  IF v_revoked_at IS NOT NULL THEN
    ROLLBACK; SET p_status_code = 'REVOKED', p_message = 'This link has been revoked.'; LEAVE sp_share_link_consume;
  ELSEIF v_expires_at < NOW() THEN
    ROLLBACK; SET p_status_code = 'EXPIRED', p_message = 'This link has expired.'; LEAVE sp_share_link_consume;
  ELSEIF v_max IS NOT NULL AND v_count >= v_max THEN
    ROLLBACK; SET p_status_code = 'LIMIT_REACHED', p_message = 'This link has reached its download limit.'; LEAVE sp_share_link_consume;
  END IF;

  UPDATE share_links SET download_count = download_count + 1 WHERE token = p_token;
  COMMIT;
  SET p_status_code = 'OK', p_message = 'Link valid.';
END$$
DELIMITER ;

8.4 Full procedure index

ProcedurePurposeKey OUT params
sp_user_authenticateFetch credential row by email for loginuser_id, password_hash, role, status
sp_user_inviteAdmin creates a user (duplicate-email checked)user_id, status_code, message
sp_folder_createPattern A abovefolder_id, status_code, message
sp_folder_rename_moveRename/move with sibling-uniqueness + cycle checkstatus_code, message
sp_folder_soft_deleteCascading soft-delete of a folder and its descendants (recursive CTE)affected_count, status_code, message
sp_folder_restoreRestore a soft-deleted folder (parent must not itself be deleted)status_code, message
sp_document_upload_commitCreate the logical document + its first version after the S3 PUT succeedsdocument_id, version_id, status_code, message
sp_document_new_versionPattern B aboveversion_id, version_no, status_code, message
sp_document_soft_deleteSoft-delete a document (idempotent)status_code, message
sp_document_restoreRestore a soft-deleted documentstatus_code, message
sp_document_searchPaginated FULLTEXT + filter search, returns a result settotal_count (OUT), result set
sp_share_link_createGenerate an expiring external linkshare_link_id, token, status_code, message
sp_share_link_consumePattern C abovedocument_id, permission, status_code, message

Every procedure is called from its Model through CodeIgniter's Query Builder::query() with bound parameters (never interpolated), and every OUT parameter is read back via a second SELECT @p_status_code, @p_message — the Model translates a non-OK status code into the matching typed exception from §5, which the global handler turns into the error envelope (§13).

9. Document Lifecycle & Versioning

9.1 Version history state

┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ v1 (is_current=1) │──upload new──▶│ v1 (is_current=0) │ │ v2 (is_current=0) │ └──────────────────┘ v2 └──────────────────┘──upload──▶└──────────────────┘ ┌──────────────────┐ │ v2 (is_current=1) │ ◀── current version served for preview/download └──────────────────┘ All prior versions remain downloadable individually via §17 "GET /documents/:id/versions/:versionId/download"

9.2 Soft-delete & restore

┌────────┐ delete ┌─────────┐ restore ┌────────┐ │ ACTIVE │────────────▶│ IN_TRASH │─────────────▶│ ACTIVE │ └────────┘ └─────────┘ └────────┘ │ retention window elapsed (trash_retention_settings) ▼ ┌────────────────┐ │ PURGED (S3 object │ scheduled Lambda, §28 — never triggered by the API itself │ removed by a separate, │ │ tighter-scoped job) │ └────────────────┘

9.3 Asynchronous post-upload processing

Browser ──PUT file bytes──▶ S3 (private bucket) │ S3 event notification (ObjectCreated) ▼ Lambda (thumbnail + metadata + scan-stub) │ writes thumbnail to S3, then calls back ▼ POST /internal/processing-callback (HMAC-signed, §5) │ ▼ updates document_processing_jobs + document_versions.thumbnail_s3_key

Uploads return to the user as soon as the S3 PUT and sp_document_upload_commit succeed — thumbnail generation never blocks the upload response. The frontend shows a "generating preview…" placeholder (§22.4) until the callback lands and the document's processing status flips to COMPLETED.

10. Private Storage & Signed URL Security

The one rule this project cannot compromise on The documents S3 bucket has Block Public Access enabled and no bucket policy statement ever grants anonymous or wildcard access. There is no code path, feature flag, or "just for the demo" exception that makes an object in this bucket reachable by a bare URL.
React (browser) │ 1. request a signed URL ▼ PHP API (CodeIgniter 4) │ 2. authenticate (JWT) + authorize (role + ACL, §6) ▼ S3Service::presignGet() / presignPut() (AWS SDK for PHP, IAM role scoped to one bucket, no ListBucket/DeleteObject) │ 3. returns a URL valid for 5 minutes, bound to one exact object key ▼ Browser ── uses the signed URL directly against S3 for the actual bytes (upload or download) (the PHP API process never streams file bytes through itself — it only ever hands out permission slips)

10.1 Why this is the README's centerpiece

This is written up verbatim as its own prominent section in README.md (§32), because it is the single design decision that most distinguishes this project from a beginner's "upload to S3" tutorial:

11. Audit Logging

Every action that touches access or data — login, folder/document create, upload, new version, download-URL issuance, share grant/revoke, share-link create/revoke, soft delete, restore, admin role change — writes one audit_logs row inside the same transaction as the action itself (never as an afterthought that could silently fail). Combined with the append-only document_versions history, an ADMIN can always answer "who has touched this file, and how" from data alone, without trusting anyone's memory of what happened.

12. API Design Conventions

Base path & versioning

All authenticated routes are prefixed /api/v1. The public share-link route is intentionally short and unversioned (/s/:token) since it is handed out in already-sent links and must never break.

Auth header

Authorization: Bearer <accessToken> on every route except /auth/login, /auth/refresh, /s/:token, and /internal/processing-callback (which instead requires X-Signature, an HMAC of the raw body).

Naming & verbs

Resources are plural nouns (/documents); non-CRUD actions are sub-resource verbs: POST /documents/:id/restore, POST /documents/:id/versions/initiate — never a verb in the base path.

Field minimalism

Response payloads return only fields the corresponding screen (§22) renders — no raw database rows, no internal S3 bucket/key details beyond what a signed-URL response needs.

13. Response & Error Envelope

13.1 Success — single resource

{
  "success": true,
  "data": { "id": 482, "name": "MSA-2026-NovaTrail.pdf", "currentVersion": 2 }
}

13.2 Success — paginated list

{
  "success": true,
  "data": [ /* array of resources */ ],
  "meta": { "page": 1, "limit": 20, "total": 86, "totalPages": 5 }
}

13.3 Error

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request payload failed validation.",
    "details": [ { "field": "name", "message": "name is required" } ]
  }
}

14. Error Code Catalog

HTTPCodeMeaning
400VALIDATION_ERRORPayload failed rule-group validation — see error.details
400UNSUPPORTED_FILE_TYPEMIME type/extension not in the allowed set (PDF, DOCX, XLSX, PNG, JPG, ZIP)
400FILE_TOO_LARGERequested upload exceeds the 25MB demo limit
401UNAUTHORIZEDMissing or expired access token
401INVALID_SIGNATUREInternal processing-callback HMAC did not match (§9.3)
403FORBIDDEN_ROLEAuthenticated, but global role lacks permission for this action
404DOCUMENT_NOT_FOUNDNo document with that id, or caller has no grant on it (never distinguished from "doesn't exist" — §6.3)
404FOLDER_NOT_FOUNDNo folder with that id, or caller has no grant on it
404SHARE_LINK_NOT_FOUNDsp_share_link_consume returned NOT_FOUND
409DUPLICATE_NAMEA folder/document with that name already exists in this location
409SHARE_LINK_EXPIREDsp_share_link_consume returned EXPIRED
409SHARE_LINK_REVOKEDsp_share_link_consume returned REVOKED
409SHARE_LINK_LIMIT_REACHEDsp_share_link_consume returned LIMIT_REACHED
409DOCUMENT_DELETEDAction attempted on a soft-deleted document (e.g. new version)
422PARENT_FOLDER_NOT_FOUNDReferenced parent folder does not exist or is deleted
429RATE_LIMITEDToo many requests from this user/IP in the current window
500INTERNAL_ERRORUnexpected failure — logged with a correlation id, no internals leaked

15. API — Auth & Users

POST/api/v1/auth/login

Public Authenticates a user and issues tokens.

AuthNone (rate-limited to 10/min/IP)
Validationemail valid format, password non-empty
DB interactionCALL sp_user_authenticate(p_email, ...); PHP compares the returned hash with password_verify()
Success200 OK — access/refresh tokens + user profile
Errors400 VALIDATION_ERROR, 401 UNAUTHORIZED (generic message — never reveals whether the email exists)

POST/api/v1/auth/refresh

Public Exchanges a valid refresh token for a new pair (rotation — old token invalidated).

POST/api/v1/auth/logout

Revokes the caller's current refresh token.

GET/api/v1/users/me

Returns the caller's profile and global role.

POST/api/v1/users

ADMIN Invites a new user (no public self-registration — this is an internal corporate tool).

AuthBearer JWT, role ADMIN
Validationname 2-120 chars; email valid + unique; role one of ADMIN/MANAGER/EMPLOYEE; a temporary password is generated server-side, never accepted from the client
DB interactionCALL sp_user_invite(...)
Success201 Created + user summary
Errors400 VALIDATION_ERROR, 403 FORBIDDEN_ROLE, 409 DUPLICATE_NAME (email already registered)

GET/api/v1/users

ADMIN Paginated team member list (§24).

PUT/api/v1/users/:id

ADMIN Updates role or status (ACTIVE/DISABLED). A disabled user's refresh tokens are revoked immediately.

16. API — Folders

POST/api/v1/folders

AuthBearer JWT, any role; caller must have EDITOR+ on the parent folder (or be creating a root folder, ADMIN-only)
Validationname 1-180 chars, no / \ : * ? " < > |; parentFolderId optional, must exist and not be deleted
DB interactionCALL sp_folder_create(...) (§8.1)
Success201 Created + folder object; creator auto-granted OWNER
Errors400 VALIDATION_ERROR, 403 FORBIDDEN_ROLE, 404 FOLDER_NOT_FOUND (bad parent), 409 DUPLICATE_NAME

GET/api/v1/folders/:id/children

Returns the immediate child folders and documents of a folder — backs the browser screen's breadcrumb navigation (§22.3). Omitting :id (i.e. /folders/root/children) returns the caller's accessible top-level folders.

PUT/api/v1/folders/:id

Rename and/or move (change parentFolderId). Requires EDITOR+ on the folder and on the destination parent. Backed by sp_folder_rename_move, which also rejects moving a folder into its own descendant (cycle check).

DELETE/api/v1/folders/:id

Soft-deletes the folder and cascades to every descendant folder/document (sp_folder_soft_delete). Requires OWNER on the folder.

POST/api/v1/folders/:id/restore

Restores from trash — 422 PARENT_FOLDER_NOT_FOUND if the immediate parent is still deleted (restore the parent first).

17. API — Documents, Upload & Versions

POST/api/v1/documents/uploads/initiate

Step 1 of the direct-to-S3 upload flow (§10).

AuthBearer JWT; caller must have EDITOR+ on the target folder
ValidationfolderId required, must exist and not be deleted; fileName required; mimeType in the allowed set; sizeBytes ≤ 25MB
DB interactionNone yet — the document row is only created once the upload is confirmed (step 3), so an abandoned upload never litters the database
Success200 OK + { uploadUrl, s3Key, expiresIn: 300 } — a presigned S3 PUT URL scoped to that exact key
Errors400 VALIDATION_ERROR, 400 UNSUPPORTED_FILE_TYPE, 400 FILE_TOO_LARGE, 404 FOLDER_NOT_FOUND
// Step 2 (frontend, not an API endpoint on this backend): PUT the file bytes directly to S3
await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } });

POST/api/v1/documents/uploads/complete

Step 3 — confirms the S3 object exists (a HEAD request against S3) and commits the document + first version.

AuthBearer JWT, same folder-level check as step 1
Validations3Key must match a key this user was just issued; name 1-200 chars; checksumSha256 required (client-computed, backend verifies against the S3 ETag for single-part uploads)
DB interactionCALL sp_document_upload_commit(...) — creates documents + document_versions rows and a PENDING processing job in one transaction
Success201 Created + document object (thumbnail absent until §9.3 completes)
Errors400 VALIDATION_ERROR, 404 FOLDER_NOT_FOUND, 409 DUPLICATE_NAME

GET/api/v1/documents/:id

Full detail including current version, thumbnail (if ready), and the caller's effective permission.

PUT/api/v1/documents/:id

Metadata-only update (name, description, tags, folder move). Requires EDITOR+.

DELETE/api/v1/documents/:id

Soft delete (sp_document_soft_delete). Requires OWNER.

POST/api/v1/documents/:id/restore

Restores from trash. Requires OWNER.

POST/api/v1/documents/:id/versions/initiate & POST/api/v1/documents/:id/versions/complete

Same three-step pattern as initial upload, scoped to an existing document; requires EDITOR+; backed by sp_document_new_version (§8.2).

GET/api/v1/documents/:id/versions

Lists all versions, newest first, each with its own download link endpoint.

18. API — Download, Preview & Sharing

GET/api/v1/documents/:id/download

AuthBearer JWT; caller must have VIEWER+ (direct grant or inherited from folder, or ADMIN)
ValidationOptional versionId query param (defaults to current); must belong to this document
DB interactionReads the version's s3_key; writes one audit_logs row (DOCUMENT_DOWNLOADED)
Success200 OK + { url, expiresIn: 300 } — a presigned S3 GET URL with response-content-disposition: attachment
Errors404 DOCUMENT_NOT_FOUND (no grant or truly absent — identical response, §6.3)

GET/api/v1/documents/:id/preview

Same authorization, returns a presigned GET URL with response-content-disposition: inline for browser-renderable types (PDF, PNG, JPG); other types fall back to the thumbnail.

GET/api/v1/documents/:id/thumbnail

Returns a presigned GET URL for the Lambda-generated thumbnail, or 404 with code THUMBNAIL_NOT_READY while processing is still pending.

POST/api/v1/documents/:id/permissions

Grants VIEWER/EDITOR/OWNER to another user. Requires OWNER on the document.

GET/api/v1/documents/:id/permissions

Lists everyone with explicit access (direct grants only — inherited folder access is shown separately in the UI, §22.5).

DELETE/api/v1/documents/:id/permissions/:userId

Revokes a direct grant. Requires OWNER; cannot revoke the last remaining OWNER.

POST/api/v1/documents/:id/share-links

AuthBearer JWT; caller must have OWNER or EDITOR
Validationpermission VIEW|DOWNLOAD; expiresInHours 1-168 (max 7 days for the demo); maxDownloads optional, 1-1000
DB interactionCALL sp_share_link_create(...)
Success201 Created + { id, url: "https://.../s/<token>", expiresAt }
Errors400 VALIDATION_ERROR, 404 DOCUMENT_NOT_FOUND

GET/api/v1/documents/:id/share-links

Lists active/expired/revoked links for a document, with download counts.

DELETE/api/v1/share-links/:id

Revokes a link immediately (sets revoked_at) — any outstanding copy of the URL stops working on its next use.

19. API — Public Share Link & Audit Log

GET/s/:token

Public, no auth Backs the public landing page (§22.11).

AuthNone — trust is the unguessable 43-character token itself
ValidationToken format (43 URL-safe base64 chars); anything else is treated as not-found rather than a validation error, to avoid distinguishing "malformed" from "doesn't exist" for an attacker
DB interactionCALL sp_share_link_consume(p_token, ...) (§8.3); on OK, a presigned GET URL is generated for the document's current version
Success200 OK + { documentName, sizeBytes, permission, downloadUrl } (downloadUrl omitted if permission is VIEW-only and the type isn't inline-previewable)
Errors404 SHARE_LINK_NOT_FOUND, 409 SHARE_LINK_EXPIRED, 409 SHARE_LINK_REVOKED, 409 SHARE_LINK_LIMIT_REACHED — the frontend renders a distinct, friendly message for each (§22.11)

POST/api/v1/internal/processing-callback

Internal, HMAC-signed Called by the Lambda worker after thumbnail/metadata processing (§9.3). Verified via X-Signature (HMAC-SHA256 of the raw body with a secret shared only with the Lambda's environment) — never a user JWT.

GET/api/v1/audit-logs

ADMIN Paginated, filterable by entityType, entityId, userId, action, and a date range.

20. Full Endpoint Index

MethodPathRolesPurpose
POST/api/v1/auth/loginPublicAuthenticate, issue tokens
POST/api/v1/auth/refreshPublicRotate access/refresh tokens
POST/api/v1/auth/logoutAnyRevoke current refresh token
GET/api/v1/users/meAnyCurrent user profile
POST/api/v1/usersADMINInvite a user
GET/api/v1/usersADMINList team members
PUT/api/v1/users/:idADMINUpdate role/status
POST/api/v1/foldersAny (EDITOR+ on parent)Create folder
GET/api/v1/folders/:id/childrenAny (VIEWER+)Browse contents
PUT/api/v1/folders/:idEDITOR+Rename/move
DELETE/api/v1/folders/:idOWNERSoft delete (cascades)
POST/api/v1/folders/:id/restoreOWNERRestore from trash
POST/api/v1/documents/uploads/initiateEDITOR+ on folderGet presigned PUT URL
POST/api/v1/documents/uploads/completeEDITOR+ on folderCommit new document
GET/api/v1/documentsAny (VIEWER+)Paginated/search list (§17, §19)
GET/api/v1/documents/:idVIEWER+Document detail
PUT/api/v1/documents/:idEDITOR+Update metadata
DELETE/api/v1/documents/:idOWNERSoft delete
POST/api/v1/documents/:id/restoreOWNERRestore from trash
POST/api/v1/documents/:id/versions/initiateEDITOR+Get presigned PUT URL for new version
POST/api/v1/documents/:id/versions/completeEDITOR+Commit new version
GET/api/v1/documents/:id/versionsVIEWER+Version history
GET/api/v1/documents/:id/downloadVIEWER+Presigned download URL
GET/api/v1/documents/:id/previewVIEWER+Presigned inline-preview URL
GET/api/v1/documents/:id/thumbnailVIEWER+Presigned thumbnail URL
POST/api/v1/documents/:id/permissionsOWNERGrant access
GET/api/v1/documents/:id/permissionsOWNERList direct grants
DELETE/api/v1/documents/:id/permissions/:userIdOWNERRevoke access
POST/api/v1/documents/:id/share-linksOWNER/EDITORCreate expiring external link
GET/api/v1/documents/:id/share-linksOWNER/EDITORList links
DELETE/api/v1/share-links/:idOWNER/EDITORRevoke link
GET/s/:tokenPublicResolve external share link
POST/api/v1/internal/processing-callbackInternal (HMAC)Lambda thumbnail/metadata callback
GET/api/v1/audit-logsADMINPaginated audit trail

21. Frontend Architecture

21.1 Data flow

React Component ──▶ React Query hook (useDocuments, useFolder, useShareLinks...) │ ▼ lib/apiClient (axios, attaches Bearer token, refreshes on 401) │ ▼ PHP CodeIgniter 4 REST API │ (mutation success invalidates the relevant query key — e.g. uploading a document invalidates ['folder', folderId, 'children'])

21.2 Upload is a client-orchestrated three-step flow

Decision
The React uploader calls initiate, PUTs to S3 directly, then calls complete — the API server never proxies file bytes.
A dedicated useDocumentUpload() hook owns this three-step sequence and exposes per-file progress via XMLHttpRequest.upload.onprogress (fetch doesn't support upload progress), used by both the folder-browser dropzone and the new-version dialog.
Why: routing file bytes through the PHP process would make it the bottleneck and the single point of failure for every upload; direct-to-S3 upload scales independently of the API tier.

22. Screens & UI/UX Specification

Eleven screens, scoped to only the fields the corresponding record actually needs (§2) — no metadata builder, no tagging taxonomy admin, no content-based search UI.

22.1 Login
/login

Fields: email* (format), password* (non-empty). No self-registration link — this is an internal tool; new accounts come from an ADMIN invite email.

States: loading (spinner on submit), error (401 → "Invalid email or password"), success (redirect to /browse).

22.2 Dashboard
/dashboard

Recently accessed documents, storage-used summary, and quick links to folders shared with the caller. Empty: "Nothing here yet — browse your folders to get started" with a link to /browse.

22.3 Folder Browser
/browse/:folderId?

Left rail folder tree, breadcrumb path, main pane grid/list toggle showing subfolders then documents. Drag-and-drop anywhere in the pane opens the upload dialog pre-targeted at the current folder. Row actions: open, rename, move, share, download, delete (each gated by the caller's resolved permission, §6 — a VIEWER never even sees the delete action, not just a disabled one).

Empty: "This folder is empty" with an "Upload files" CTA (only shown if the caller has EDITOR+). Loading: skeleton rows matching the grid/list layout. Confirm: deleting a folder/document opens a dialog naming it and stating "Moves to Trash — can be restored within 30 days."

┌───────────────────────────────────────────────────────────┐ │ Home / Client Contracts / NovaTrail Logistics [Upload ▾] │ ├───────────────┬───────────────────────────────────────────┤ │ ▸ Client │ 📁 2026 Renewals 📁 Invoices │ │ Contracts │ 📄 MSA-2026-NovaTrail.pdf v2 ⭐ Shared │ │ ▸ HR Records │ 📄 SOW-Q1-NovaTrail.docx v1 │ │ ▸ Compliance │ │ └───────────────┴───────────────────────────────────────────┘
22.4 Document Detail / Preview Panel
/documents/:id (slide-over panel, not a full navigation)

Tabs: Preview (inline for PDF/image, "generating preview…" placeholder while §9.3 is pending, download-only fallback for DOCX/XLSX/ZIP), Versions (list with per-version download + "Restore this version as new version"), Sharing (§22.5), Activity (audit log entries scoped to this document — visible to OWNER only).

Fields on rename: name* (1-200 chars), description (optional, 500 chars), tags (optional, comma-separated chips).

22.5 Share Dialog
modal, opened from Document Detail or a row action

Internal sharing: user picker (search by name/email) + permission select (Viewer/Editor/Owner) → calls §18's grant endpoint; existing grants listed with a revoke icon. External link: permission (View/Download), expiry (1 day / 7 days / custom up to 7 days — validated client- and server-side), optional max-download count → generates a copyable URL with a visible countdown to expiry.

Confirmation: revoking a link or a user's access shows "This cannot be undone — they will lose access immediately" before confirming.

22.6 Upload Dialog
modal

Multi-file drop zone; each file shows name, size, a client-side MIME/size check (rejects unsupported types before even calling initiate), and a progress bar during the direct-to-S3 PUT. Error per-file: "File too large (max 25MB)" / "File type not supported" shown inline without blocking the other files in the batch.

22.7 Search Results
/search?q=...

Backed by sp_document_search (FULLTEXT on name/description/tags). Results show the folder breadcrumb for context since matches can come from anywhere the caller has access to. Empty: "No documents match '{query}'" distinct from the folder-empty state.

22.8 Trash
/trash

Soft-deleted folders and documents the caller owns, with days-remaining-until-purge shown per item (from trash_retention_settings). Restore action re-validates the parent folder isn't itself deleted (§8.4) and surfaces a clear message if it is: "Restore the parent folder '{name}' first."

22.9 Audit Log
/admin/audit-log — ADMIN only

Filterable table: date range, action, entity type, user. Each row expands to show the JSON details payload for that event.

22.10 User Management
/admin/users — ADMIN only

Invite fields: name*, email* (unique), role* (Admin/Manager/Employee). Disabling a user shows a confirm dialog: "They will be signed out immediately and lose all access."

22.11 Public Share Landing Page
/s/:token — no auth, no app shell

Minimal branded page showing the document name, size, and a Download/View button once §19's resolve call succeeds. Distinct messages per failure: "This link has expired", "This link has been revoked by its owner", "This link has reached its download limit", "This link is not valid" — never a generic error for all four.

23. UI & Validation Standards

24. Pagination, Filtering & Sorting

GET /api/v1/documents?folderId=42&page=1&limit=20&search=nova&mimeType=application/pdf&sort=updatedAt&direction=desc

Every list endpoint shares one pagination request shape (page ≥ 1 default 1, limit 1-100 default 20, resource-specific filters, sort allow-listed per resource, direction asc|desc) and one paginated response shape (§13.2), so the shared DataTable/grid component works identically across Documents, Folders (children), Users, and Audit Logs.

25. Testing Strategy

Backend

  • Unit tests per Service (PHPUnit) — authorization resolution (§6.3), validation edge cases
  • Feature tests (CIUnitTestCase + FeatureTestTrait) per Controller against a MySQL test database with the real stored procedures loaded — not mocked, since the business rules live in them
  • A dedicated concurrency test for sp_document_new_version: two parallel connections call it for the same document, asserting no duplicate version_no is ever produced (proves the row lock in §8.2 actually works)
  • The permission-inheritance guardrail test from §6.3 (non-participant gets 404, never 403)

Frontend

  • Vitest + React Testing Library for the upload hook's three-step orchestration and the permission-aware menu rendering
  • MSW mocks the API — no real network calls in component tests
  • Playwright smoke suite: login → upload a file → create a version → generate a share link → open it in an incognito context and confirm the download works without a session

26. API Documentation (OpenAPI)

Generated from PHP doc-comment annotations via zircote/swagger-php, served at /api/docs in non-production environments. Every route's rule group is mirrored in its @OA\RequestBody schema so the generated spec cannot silently drift from the actual validation rules enforced in §12–§19.

27. Docker & Local Development

# infrastructure/docker-compose.yml services
api         # PHP-FPM + CodeIgniter 4, port 8080
web         # nginx serving the built React app in dev-parity mode
mysql       # MySQL 8, auto-runs migrations + stored-procedure scripts + DemoSeeder on first boot
localstack  # emulates S3 + Lambda locally — no AWS account needed for local dev
mailhog     # catches the ADMIN user-invite emails

docker compose up brings up the full stack; php spark db:seed DemoSeeder loads the fictional "Meridian Consulting Group" with sample folders (Client Contracts, HR Records, Compliance) and synthetic PDF/DOCX/image documents for screenshots.

28. AWS Deployment Architecture

┌────────────┐ │ CloudFront │──▶ S3 (React build, public) └────────────┘ │ ┌────────────┐ Users ─────────▶ │ API (PHP-FPM on a │ │ single free-tier EC2) │ └────────────┘ │ │ ┌────────┘ └─────────────┐ ▼ ▼ ┌──────────────┐ ┌───────────────────────┐ │ MySQL (RDS) │ │ S3 (documents, PRIVATE) │◀── presigned PUT/GET only └──────────────┘ └───────────────────────┘ │ S3 event (ObjectCreated) ▼ ┌────────────────────┐ │ Lambda: thumbnail + │──▶ callback to API (§9.3) │ metadata + scan-stub │ └────────────────────┘ │ scheduled (EventBridge) ▼ ┌────────────────────┐ │ Lambda: purge trash │ tighter IAM role — only this │ past retention window │ function may call s3:DeleteObject └────────────────────┘ CloudWatch Logs/Alarms wraps every component above

Deployment infra is documented (Terraform snippets under infrastructure/aws/) but treated as optional for a reviewer — the Docker Compose stack in §27 is the primary "clone and run" path.

29. Build Phases & Roadmap

Phase 0
Foundation
~4 days
  • Migrations for all 10 tables + stored procedures
  • Auth (login/refresh) + JWT filter
  • Docker Compose (MySQL, LocalStack, Mailhog)
Phase 1
Folders & documents core
~1 week
  • Folder CRUD + soft delete/restore
  • Three-step upload flow + versioning
Phase 2
Storage security
~4 days
  • S3Service presign put/get, IAM least-privilege policy
  • Download/preview endpoints, thumbnail plumbing
Phase 3
Sharing & permissions
~1 week
  • Document/folder permission grants + inheritance
  • Expiring share links + public landing page
Phase 4
Async processing
~4 days
  • Lambda thumbnail/metadata worker + HMAC callback
  • Search (FULLTEXT), audit log viewer
Phase 5
Hardening
~4 days
  • Rate limiting, security headers, concurrency test for versioning
  • Full test pass, OpenAPI polish
Phase 6
Portfolio polish
~3 days
  • README's signed-URL security write-up, screenshots
  • Seed data, demo script, optional AWS deploy walkthrough

30. Risks & Mitigations

RiskMitigation
A misconfiguration accidentally makes the documents bucket publicBlock Public Access enabled at the bucket and account level in the Terraform module; a CI check asserts the Terraform plan contains no public-read statement before apply
Presigned URL leaked (forwarded, logged) grants long-term access5-minute TTL on all presigned URLs (§10); long-term sharing must go through the revocable, audited share_links path instead
Two concurrent uploads race on the same document's next version numberSELECT ... FOR UPDATE row lock inside sp_document_new_version (§8.2), covered by a dedicated concurrency test (§25)
An abandoned upload (initiate called, file never PUT) litters storage/DBThe document row is only created in step 3 (complete); an unused presigned URL just expires after 5 minutes with nothing to clean up
Reviewer can't run the AWS parts without an accountDocker Compose + LocalStack path is the default, documented first (§27); AWS is clearly marked optional

31. Copyright, Trademark & Branding

Arsi India Info
Arsi India Info — Innovate · Integrate · Elevate.
Author of record for this repository and every file in it.

31.1 Two-layer licensing

Source code is released under the MIT License (already present at the repo root). The Arsi India Info name and logo are covered separately by TRADEMARK.md: forks and derivatives are welcome, but must not present themselves as Arsi India Info's own product or reuse its logo/branding.

31.2 Where the signature lives

SurfaceBranding element
FrontendPersistent <BrandFooter/> component on every page: "© 2026 Arsi India Info"
APIX-Powered-By: Arsi-India-Info response header on every route
APIGET /api/v1/about — public endpoint returning project + author metadata
OpenAPIinfo.contact and info.license fields point to Arsi India Info
Source filesCopyright header banner in every PHP/TS file, enforced by a CI license-header-check step
Public share pageSmall "Secured by Arsi India Info" footnote — visible even to unauthenticated recipients
This documentSidebar, hero seal, and footer (below)

31.3 Honest limits of watermarking a public repo

A footer string or logo can be stripped by a determined fork. Real proof of authorship comes from GitHub itself: commits under the Arsi India Info GitHub org, signed commits, a CODEOWNERS file, and SHA-256 checksums on tagged releases. This plan treats visible branding as presentation, not enforcement.

32. Documentation Set & Portfolio Usage

DocumentLocationCovers
README.mdrepo root (already present, expanded)Pitch, feature list, architecture diagram, the §10 signed-URL security write-up, screenshots, quick start
API docs/api/docs (OpenAPI UI) + docs/api.md exportEvery endpoint in §15–§20
Frontend startupdocs/frontend-setup.mdnpm install, .env.example, npm run dev
Backend startupdocs/backend-setup.mdcomposer install, .env setup, migrations + stored procedures, php spark serve
Database referencedocs/data-model.mdTable inventory (§7.1), ER diagram, stored-procedure index (§8.4)
Testing guidedocs/testing.mdcomposer test, npm run test, coverage thresholds
Deployment guidedocs/deployment.mdDocker Compose (primary) and AWS/Terraform (optional) — §27, §28
Git/GitHub workflowdocs/contributing.mdBranch naming, Conventional Commits, PR template, required CI checks
Portfolio/demo usagedocs/portfolio-demo.mdA 5-minute walkthrough: seed data, upload a document, share it externally, open the link in an incognito window

33. Definition of Done