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.
1. Executive Summary
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.
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
backend/ (CodeIgniter 4 appstarter), frontend/ (Vite React/TS), docs/, infrastructure/ — and build inside it rather than restructuring.backend/.env). This plan fills in app/ and src/, it does not reorganize what's already there.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,ConfirmDialogused 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 baseApiExceptioncarryinghttpStatusanderrorCode - A global exception handler (
app/Config/Exceptions.phpoverride) 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_ERRORwith 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:ListBucketors3: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/.envis git-ignored, only.env.exampleis 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_dumpdebugging 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.phpandapp/Config/Auth.php, sourced from.envvia CodeIgniter'senv()helper — no hard-coded environment values in business code - Per-environment
.env.exampledocumented 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
| Role | Manage users | View audit log | Access any folder by default | Typical use |
|---|---|---|---|---|
| ADMIN | Yes | Yes | Yes | IT/records administrator |
| MANAGER | No | No | Only folders shared with them | Department head |
| EMPLOYEE | No | No | Only folders/files shared with them | Regular staff member |
6.2 Per-resource permission (sharing)
| Permission | View/preview | Download | Upload new version | Rename/move | Share with others | Delete |
|---|---|---|---|---|---|---|
| VIEWER | Yes | Yes | No | No | No | No |
| EDITOR | Yes | Yes | Yes | Yes | No | No |
| OWNER | Yes | Yes | Yes | Yes | Yes | Yes (soft) |
6.3 How access is resolved
- The uploader of a document (or creator of a folder) is granted OWNER automatically.
- 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).
- 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.
- 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.
404 (not 403), so a non-participant can never learn that a document even exists.
7. Database Schema (MySQL)
7.1 Table inventory
| Table | Purpose | Key relationships |
|---|---|---|
users | Accounts, credentials, global role | — |
refresh_tokens | Hashed refresh tokens for rotation | FK → users |
folders | Nested folder tree (adjacency list) | FK parent_folder_id → folders; FK created_by → users |
documents | Logical file record (name, folder, tags, soft-delete) | FK → folders, users |
document_versions | One row per uploaded version — the actual S3 object pointer | FK → documents, users |
document_permissions | Internal user-to-user sharing grants, folder or document scoped | FK → folders/documents, users |
share_links | Expiring external download links (token-based) | FK → documents, users |
document_processing_jobs | Async thumbnail/metadata pipeline status per version | FK → document_versions |
audit_logs | Immutable record of every sensitive action | FK → users (nullable, for system actions) |
trash_retention_settings | Single-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
- Soft delete only at the API/application level (
deleted_at); a real S3 object is never deleted by the application — a separate, tightly-scoped scheduled Lambda purges S3 objects for documents past the retention window intrash_retention_settings, so a compromised API credential cannot mass-delete stored files. - FULLTEXT index on
documents(name, description, tags)backs §17 search without introducing Elasticsearch — appropriate for the portfolio's metadata-only search scope (§2). - All FKs use
ON DELETE RESTRICT(except permission grants, which cascade with their parent resource) — nothing in this schema is ever hard-deleted by a cascade the developer didn't explicitly choose.
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
| Procedure | Purpose | Key OUT params |
|---|---|---|
sp_user_authenticate | Fetch credential row by email for login | user_id, password_hash, role, status |
sp_user_invite | Admin creates a user (duplicate-email checked) | user_id, status_code, message |
sp_folder_create | Pattern A above | folder_id, status_code, message |
sp_folder_rename_move | Rename/move with sibling-uniqueness + cycle check | status_code, message |
sp_folder_soft_delete | Cascading soft-delete of a folder and its descendants (recursive CTE) | affected_count, status_code, message |
sp_folder_restore | Restore a soft-deleted folder (parent must not itself be deleted) | status_code, message |
sp_document_upload_commit | Create the logical document + its first version after the S3 PUT succeeds | document_id, version_id, status_code, message |
sp_document_new_version | Pattern B above | version_id, version_no, status_code, message |
sp_document_soft_delete | Soft-delete a document (idempotent) | status_code, message |
sp_document_restore | Restore a soft-deleted document | status_code, message |
sp_document_search | Paginated FULLTEXT + filter search, returns a result set | total_count (OUT), result set |
sp_share_link_create | Generate an expiring external link | share_link_id, token, status_code, message |
sp_share_link_consume | Pattern C above | document_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
9.2 Soft-delete & restore
9.3 Asynchronous post-upload processing
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
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:
- The bucket is private because any public bucket is one misconfigured object ACL away from an open data leak — there is no reason a company's contracts and HR letters should ever be one guessable URL away from the public internet.
- The backend generates the signed URL, never the frontend, because the frontend cannot be trusted to enforce authorization — only server-side code that has just checked "does this user have VIEWER+ on this document" can be trusted to mint a credential that grants access to it.
- The URL expires in minutes, not hours, so a leaked link (forwarded email, browser history, proxy log) stops working quickly — long-lived sharing instead goes through the explicit, revocable, audit-logged
share_linksmechanism (§17), never a long-TTL presigned URL.
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
| HTTP | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | Payload failed rule-group validation — see error.details |
| 400 | UNSUPPORTED_FILE_TYPE | MIME type/extension not in the allowed set (PDF, DOCX, XLSX, PNG, JPG, ZIP) |
| 400 | FILE_TOO_LARGE | Requested upload exceeds the 25MB demo limit |
| 401 | UNAUTHORIZED | Missing or expired access token |
| 401 | INVALID_SIGNATURE | Internal processing-callback HMAC did not match (§9.3) |
| 403 | FORBIDDEN_ROLE | Authenticated, but global role lacks permission for this action |
| 404 | DOCUMENT_NOT_FOUND | No document with that id, or caller has no grant on it (never distinguished from "doesn't exist" — §6.3) |
| 404 | FOLDER_NOT_FOUND | No folder with that id, or caller has no grant on it |
| 404 | SHARE_LINK_NOT_FOUND | sp_share_link_consume returned NOT_FOUND |
| 409 | DUPLICATE_NAME | A folder/document with that name already exists in this location |
| 409 | SHARE_LINK_EXPIRED | sp_share_link_consume returned EXPIRED |
| 409 | SHARE_LINK_REVOKED | sp_share_link_consume returned REVOKED |
| 409 | SHARE_LINK_LIMIT_REACHED | sp_share_link_consume returned LIMIT_REACHED |
| 409 | DOCUMENT_DELETED | Action attempted on a soft-deleted document (e.g. new version) |
| 422 | PARENT_FOLDER_NOT_FOUND | Referenced parent folder does not exist or is deleted |
| 429 | RATE_LIMITED | Too many requests from this user/IP in the current window |
| 500 | INTERNAL_ERROR | Unexpected 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.
| Auth | None (rate-limited to 10/min/IP) |
| Validation | email valid format, password non-empty |
| DB interaction | CALL sp_user_authenticate(p_email, ...); PHP compares the returned hash with password_verify() |
| Success | 200 OK — access/refresh tokens + user profile |
| Errors | 400 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).
| Auth | Bearer JWT, role ADMIN |
| Validation | name 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 interaction | CALL sp_user_invite(...) |
| Success | 201 Created + user summary |
| Errors | 400 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
| Auth | Bearer JWT, any role; caller must have EDITOR+ on the parent folder (or be creating a root folder, ADMIN-only) |
| Validation | name 1-180 chars, no / \ : * ? " < > |; parentFolderId optional, must exist and not be deleted |
| DB interaction | CALL sp_folder_create(...) (§8.1) |
| Success | 201 Created + folder object; creator auto-granted OWNER |
| Errors | 400 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).
| Auth | Bearer JWT; caller must have EDITOR+ on the target folder |
| Validation | folderId required, must exist and not be deleted; fileName required; mimeType in the allowed set; sizeBytes ≤ 25MB |
| DB interaction | None yet — the document row is only created once the upload is confirmed (step 3), so an abandoned upload never litters the database |
| Success | 200 OK + { uploadUrl, s3Key, expiresIn: 300 } — a presigned S3 PUT URL scoped to that exact key |
| Errors | 400 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.
| Auth | Bearer JWT, same folder-level check as step 1 |
| Validation | s3Key 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 interaction | CALL sp_document_upload_commit(...) — creates documents + document_versions rows and a PENDING processing job in one transaction |
| Success | 201 Created + document object (thumbnail absent until §9.3 completes) |
| Errors | 400 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
| Auth | Bearer JWT; caller must have VIEWER+ (direct grant or inherited from folder, or ADMIN) |
| Validation | Optional versionId query param (defaults to current); must belong to this document |
| DB interaction | Reads the version's s3_key; writes one audit_logs row (DOCUMENT_DOWNLOADED) |
| Success | 200 OK + { url, expiresIn: 300 } — a presigned S3 GET URL with response-content-disposition: attachment |
| Errors | 404 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
| Auth | Bearer JWT; caller must have OWNER or EDITOR |
| Validation | permission VIEW|DOWNLOAD; expiresInHours 1-168 (max 7 days for the demo); maxDownloads optional, 1-1000 |
| DB interaction | CALL sp_share_link_create(...) |
| Success | 201 Created + { id, url: "https://.../s/<token>", expiresAt } |
| Errors | 400 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).
| Auth | None — trust is the unguessable 43-character token itself |
| Validation | Token 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 interaction | CALL sp_share_link_consume(p_token, ...) (§8.3); on OK, a presigned GET URL is generated for the document's current version |
| Success | 200 OK + { documentName, sizeBytes, permission, downloadUrl } (downloadUrl omitted if permission is VIEW-only and the type isn't inline-previewable) |
| Errors | 404 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
| Method | Path | Roles | Purpose |
|---|---|---|---|
| POST | /api/v1/auth/login | Public | Authenticate, issue tokens |
| POST | /api/v1/auth/refresh | Public | Rotate access/refresh tokens |
| POST | /api/v1/auth/logout | Any | Revoke current refresh token |
| GET | /api/v1/users/me | Any | Current user profile |
| POST | /api/v1/users | ADMIN | Invite a user |
| GET | /api/v1/users | ADMIN | List team members |
| PUT | /api/v1/users/:id | ADMIN | Update role/status |
| POST | /api/v1/folders | Any (EDITOR+ on parent) | Create folder |
| GET | /api/v1/folders/:id/children | Any (VIEWER+) | Browse contents |
| PUT | /api/v1/folders/:id | EDITOR+ | Rename/move |
| DELETE | /api/v1/folders/:id | OWNER | Soft delete (cascades) |
| POST | /api/v1/folders/:id/restore | OWNER | Restore from trash |
| POST | /api/v1/documents/uploads/initiate | EDITOR+ on folder | Get presigned PUT URL |
| POST | /api/v1/documents/uploads/complete | EDITOR+ on folder | Commit new document |
| GET | /api/v1/documents | Any (VIEWER+) | Paginated/search list (§17, §19) |
| GET | /api/v1/documents/:id | VIEWER+ | Document detail |
| PUT | /api/v1/documents/:id | EDITOR+ | Update metadata |
| DELETE | /api/v1/documents/:id | OWNER | Soft delete |
| POST | /api/v1/documents/:id/restore | OWNER | Restore from trash |
| POST | /api/v1/documents/:id/versions/initiate | EDITOR+ | Get presigned PUT URL for new version |
| POST | /api/v1/documents/:id/versions/complete | EDITOR+ | Commit new version |
| GET | /api/v1/documents/:id/versions | VIEWER+ | Version history |
| GET | /api/v1/documents/:id/download | VIEWER+ | Presigned download URL |
| GET | /api/v1/documents/:id/preview | VIEWER+ | Presigned inline-preview URL |
| GET | /api/v1/documents/:id/thumbnail | VIEWER+ | Presigned thumbnail URL |
| POST | /api/v1/documents/:id/permissions | OWNER | Grant access |
| GET | /api/v1/documents/:id/permissions | OWNER | List direct grants |
| DELETE | /api/v1/documents/:id/permissions/:userId | OWNER | Revoke access |
| POST | /api/v1/documents/:id/share-links | OWNER/EDITOR | Create expiring external link |
| GET | /api/v1/documents/:id/share-links | OWNER/EDITOR | List links |
| DELETE | /api/v1/share-links/:id | OWNER/EDITOR | Revoke link |
| GET | /s/:token | Public | Resolve external share link |
| POST | /api/v1/internal/processing-callback | Internal (HMAC) | Lambda thumbnail/metadata callback |
| GET | /api/v1/audit-logs | ADMIN | Paginated audit trail |
21. Frontend Architecture
21.1 Data flow
21.2 Upload is a client-orchestrated three-step flow
initiate, PUTs to S3 directly, then calls complete — the API server never proxies file bytes.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.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.
/loginFields: 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).
/dashboardRecently 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.
/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."
/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).
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.
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.
/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.
/trashSoft-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."
/admin/audit-log — ADMIN onlyFilterable table: date range, action, entity type, user. Each row expands to show the JSON details payload for that event.
/admin/users — ADMIN onlyInvite 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."
/s/:token — no auth, no app shellMinimal 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
- Required fields marked with a trailing
*; the same Zod schema used client-side mirrors the CI4 rule group server-side. - Permission-aware UI: an action the caller cannot perform is hidden, not merely disabled — a VIEWER's context menu simply has fewer items than an OWNER's, rather than greyed-out ones with a tooltip explaining why.
- Loading state: skeletons matching the real layout (folder grid, table rows) rather than a bare spinner.
- Empty vs. no-results: "This folder is empty" (creation CTA) is always visually distinct from "No documents match your search" (clear-filters action).
- Destructive actions (delete, revoke share, disable user) always confirm by naming the specific record and its consequence.
- Toasts confirm every successful mutation ("Document restored", "Link revoked"); field-level errors surface inline first.
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 duplicateversion_nois 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
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
- Migrations for all 10 tables + stored procedures
- Auth (login/refresh) + JWT filter
- Docker Compose (MySQL, LocalStack, Mailhog)
- Folder CRUD + soft delete/restore
- Three-step upload flow + versioning
- S3Service presign put/get, IAM least-privilege policy
- Download/preview endpoints, thumbnail plumbing
- Document/folder permission grants + inheritance
- Expiring share links + public landing page
- Lambda thumbnail/metadata worker + HMAC callback
- Search (FULLTEXT), audit log viewer
- Rate limiting, security headers, concurrency test for versioning
- Full test pass, OpenAPI polish
- README's signed-URL security write-up, screenshots
- Seed data, demo script, optional AWS deploy walkthrough
30. Risks & Mitigations
| Risk | Mitigation |
|---|---|
| A misconfiguration accidentally makes the documents bucket public | Block 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 access | 5-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 number | SELECT ... 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/DB | The 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 account | Docker Compose + LocalStack path is the default, documented first (§27); AWS is clearly marked optional |
31. Copyright, Trademark & Branding
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
| Surface | Branding element |
|---|---|
| Frontend | Persistent <BrandFooter/> component on every page: "© 2026 Arsi India Info" |
| API | X-Powered-By: Arsi-India-Info response header on every route |
| API | GET /api/v1/about — public endpoint returning project + author metadata |
| OpenAPI | info.contact and info.license fields point to Arsi India Info |
| Source files | Copyright header banner in every PHP/TS file, enforced by a CI license-header-check step |
| Public share page | Small "Secured by Arsi India Info" footnote — visible even to unauthenticated recipients |
| This document | Sidebar, 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
| Document | Location | Covers |
|---|---|---|
| README.md | repo 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 export | Every endpoint in §15–§20 |
| Frontend startup | docs/frontend-setup.md | npm install, .env.example, npm run dev |
| Backend startup | docs/backend-setup.md | composer install, .env setup, migrations + stored procedures, php spark serve |
| Database reference | docs/data-model.md | Table inventory (§7.1), ER diagram, stored-procedure index (§8.4) |
| Testing guide | docs/testing.md | composer test, npm run test, coverage thresholds |
| Deployment guide | docs/deployment.md | Docker Compose (primary) and AWS/Terraform (optional) — §27, §28 |
| Git/GitHub workflow | docs/contributing.md | Branch naming, Conventional Commits, PR template, required CI checks |
| Portfolio/demo usage | docs/portfolio-demo.md | A 5-minute walkthrough: seed data, upload a document, share it externally, open the link in an incognito window |
33. Definition of Done
- All 31 endpoints in §20 implemented, validated, and covered by at least one feature test
- Every screen in §22 has loading, empty, error, and success states implemented — not just the happy path
- A full document lifecycle (upload → new version → internal share → external share link → download via the public page → soft delete → restore) is demonstrable end-to-end with one seed script and no manual DB edits
- The permission-inheritance guardrail test (§6.3) and the version-numbering concurrency test (§25) both pass in CI
- The documents S3 bucket has Block Public Access verified enabled, and a CI check fails the build if the Terraform plan would ever change that
- README, API docs, and the portfolio demo script (§32) are complete enough that someone who has never seen the repo can run it in under 10 minutes
- Branding present per the §31.2 table; LICENSE and TRADEMARK.md committed