feat: switch from MySQL to SQLite for zero-dependency deployment

- Rewrite db.php to use SQLite via PDO (no external DB server needed)
- Rewrite schema.sql for SQLite (AUTOINCREMENT, CHECK constraints,
  triggers for updated_at, ON CONFLICT instead of ON DUPLICATE KEY)
- Fix config.php upsert to use ON CONFLICT syntax
- Fix index.php: tag filter via json_each, vote upsert via ON CONFLICT,
  and fix protocol create route (was returning 405 for POST /api/protocols)
- Fix yaml.php seed import: replace invalid false callbacks with fn() => null,
  fix yaml_parse pos argument (-1 -> 0)
- Update Dockerfile: drop pdo_mysql/mysqli (pdo_sqlite is built in)
- Update CloudronManifest.json: remove mysql addon, bump to v0.3.0
- Update README for SQLite deployment
This commit is contained in:
Protocolbot
2026-07-06 14:42:45 -06:00
parent 7379d0c540
commit 3386ac6542
8 changed files with 155 additions and 132 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
FROM php:8.2-apache FROM php:8.2-apache
# Install MySQL PDO extension and YAML extension # Install YAML extension (pdo_sqlite is already included in php:8.2-apache)
RUN docker-php-ext-install pdo pdo_mysql mysqli RUN docker-php-ext-install pdo
# Install PECL yaml extension # Install PECL yaml extension
RUN apt-get update && apt-get install -y libyaml-dev && \ RUN apt-get update && apt-get install -y libyaml-dev && \
@@ -18,7 +18,7 @@ RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-av
# Copy app files # Copy app files
COPY . /var/www/html/ COPY . /var/www/html/
# Ensure data directory is writable # Ensure data directory is writable (for SQLite database)
RUN mkdir -p /var/www/html/data && chmod 777 /var/www/html/data RUN mkdir -p /var/www/html/data && chmod 777 /var/www/html/data
EXPOSE 80 EXPOSE 80
+29 -17
View File
@@ -17,6 +17,7 @@ Protocol Droid is a web application where communities can document, share, and r
- **Identity**: user accounts with optional SSO support - **Identity**: user accounts with optional SSO support
- **YAML import/export**: protocols are fully structured YAML — portable, git-diffable, human-readable - **YAML import/export**: protocols are fully structured YAML — portable, git-diffable, human-readable
- **Whitelabel**: customize site name, tagline, and theme via `config.yaml` - **Whitelabel**: customize site name, tagline, and theme via `config.yaml`
- **Zero-dependency database**: SQLite — no external database server required
## Architecture ## Architecture
@@ -24,7 +25,7 @@ Protocol Droid is a web application where communities can document, share, and r
protocol-droid/ protocol-droid/
├── api/ PHP backend (REST API) ├── api/ PHP backend (REST API)
│ ├── index.php API router (all /api/* requests) │ ├── index.php API router (all /api/* requests)
│ ├── db.php Database connection + helpers (MySQL/MariaDB) │ ├── db.php Database connection + helpers (SQLite via PDO)
│ ├── auth.php Session-based authentication │ ├── auth.php Session-based authentication
│ ├── config.php Site configuration │ ├── config.php Site configuration
│ └── yaml.php YAML import/export helpers │ └── yaml.php YAML import/export helpers
@@ -41,7 +42,8 @@ protocol-droid/
│ └── fishbowl-discussion.yaml │ └── fishbowl-discussion.yaml
├── cloudron/ Cloudron deployment files ├── cloudron/ Cloudron deployment files
│ └── CloudronManifest.json │ └── CloudronManifest.json
├── schema.sql MySQL database schema ├── data/ SQLite database (auto-created)
├── schema.sql SQLite database schema
├── config.yaml Whitelabel configuration ├── config.yaml Whitelabel configuration
├── .htaccess Apache rewrite rules ├── .htaccess Apache rewrite rules
└── README.md This file └── README.md This file
@@ -49,19 +51,19 @@ protocol-droid/
## Stack ## Stack
- **Backend**: PHP 8.x with PDO (MySQL/MariaDB) - **Backend**: PHP 8.x with PDO (SQLite)
- **Database**: MySQL/MariaDB (Cloudron-native) - **Database**: SQLite — file-based, no server required
- **Frontend**: Vanilla HTML/CSS/JS — no frameworks, no build step, no external dependencies - **Frontend**: Vanilla HTML/CSS/JS — no frameworks, no build step, no external dependencies
- **Data format**: YAML for portability, MySQL for runtime - **Data format**: YAML for portability, SQLite for runtime
- **Auth**: Session-based with optional SSO - **Auth**: Session-based with optional SSO
## Deployment ## Deployment
### On Cloudron ### On Cloudron
1. Create a LAMP app in Cloudron 1. Create a LAMP app in Cloudron (no MySQL addon needed)
2. Upload all files to the app's web root 2. Upload all files to the app's web root
3. The database schema auto-initializes on first API request 3. The database schema auto-initializes on first API request (creates `data/protocol_droid.db`)
4. Visit the site and register — the **first user to register becomes admin** 4. Visit the site and register — the **first user to register becomes admin**
5. Optionally load seed protocols: sign in as admin and `POST /api/seed` (or use the API endpoint with curl: `curl -b cookies.txt -X POST https://your-site/api/seed`) 5. Optionally load seed protocols: sign in as admin and `POST /api/seed` (or use the API endpoint with curl: `curl -b cookies.txt -X POST https://your-site/api/seed`)
@@ -70,25 +72,34 @@ protocol-droid/
### On any LAMP server ### On any LAMP server
1. Upload files to your web root (or a subdirectory like `/protocol-droid/`) 1. Upload files to your web root (or a subdirectory like `/protocol-droid/`)
2. Create a MySQL database and user 2. Ensure the `data/` directory is writable by the web server
3. Set environment variables (or edit `api/db.php`): 3. Visit the site and register — first user becomes admin
- `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_DATABASE`, `MYSQL_USER`, `MYSQL_PASSWORD`
4. Visit the site and register — first user becomes admin The SQLite database file is created automatically at `data/protocol_droid.db`. To use a custom path, set the `DATABASE_PATH` environment variable.
**Subdirectory deployment:** Protocol Droid works in a subdirectory (e.g. `example.com/protocol-droid/`). The root `index.php` auto-detects the base path from the server's script name. No configuration needed — just upload the files to the subdirectory and ensure `index.php` is served as the directory index (most LAMP setups do this by default with `DirectoryIndex index.php` or `.htaccess`). **Subdirectory deployment:** Protocol Droid works in a subdirectory (e.g. `example.com/protocol-droid/`). The root `index.php` auto-detects the base path from the server's script name. No configuration needed — just upload the files to the subdirectory and ensure `index.php` is served as the directory index (most LAMP setups do this by default with `DirectoryIndex index.php` or `.htaccess`).
### With Docker
```bash
# Build and run a single container (no separate database container needed)
docker build -t protocol-droid .
docker run -d --name pd-web -p 8080:80 \
-v pd_data:/var/www/html/data \
protocol-droid
```
The SQLite database lives in the `pd_data` volume. The `docker-compose.yml` included in the repo runs the same setup.
### Local development ### Local development
```bash ```bash
# Start PHP built-in server # Start PHP built-in server
cd protocol-droid cd protocol-droid
php -S localhost:8000 php -S localhost:8000
# Or with a MySQL database:
MYSQL_HOST=localhost MYSQL_DATABASE=protocol_droid MYSQL_USER=root MYSQL_PASSWORD=secret php -S localhost:8000
``` ```
The first registration creates the admin account. To load the 6 sample protocols (Round Robin Check-In, Consent Decision-Making, etc.): The SQLite database is created automatically at `data/protocol_droid.db`. The first registration creates the admin account. To load the 6 sample protocols (Round Robin Check-In, Consent Decision-Making, etc.):
```bash ```bash
# After registering as admin: # After registering as admin:
@@ -112,14 +123,15 @@ curl -b cookies.txt -X DELETE http://localhost:8000/api/protocols/round-robin-ch
| `/api` | GET | API info | none | | `/api` | GET | API info | none |
| `/api/config` | GET | Site configuration | none | | `/api/config` | GET | Site configuration | none |
| `/api/protocols` | GET | List public protocols | none | | `/api/protocols` | GET | List public protocols | none |
| `/api/protocols/:slug` | GET | Get protocol | none |
| `/api/protocols` | POST | Create protocol | member | | `/api/protocols` | POST | Create protocol | member |
| `/api/protocols/:slug` | GET | Get protocol | none |
| `/api/protocols/:slug` | PUT | Update protocol | author/admin | | `/api/protocols/:slug` | PUT | Update protocol | author/admin |
| `/api/protocols/:slug` | DELETE | Delete protocol | author/admin | | `/api/protocols/:slug` | DELETE | Delete protocol | author/admin |
| `/api/protocols/:slug/yaml` | GET | Export as YAML | none | | `/api/protocols/:slug/yaml` | GET | Export as YAML | none |
| `/api/protocols/:slug/votes` | GET | Get votes for protocol | none |
| `/api/collections` | GET | List collections | none | | `/api/collections` | GET | List collections | none |
| `/api/collections/:slug` | GET | Get collection | none |
| `/api/collections` | POST | Create collection | member | | `/api/collections` | POST | Create collection | member |
| `/api/collections/:slug` | GET | Get collection | none |
| `/api/collections/:slug` | PUT | Update collection | author/admin | | `/api/collections/:slug` | PUT | Update collection | author/admin |
| `/api/collections/:slug` | DELETE | Delete collection | author/admin | | `/api/collections/:slug` | DELETE | Delete collection | author/admin |
| `/api/auth/register` | POST | Register | none | | `/api/auth/register` | POST | Register | none |
Executable → Regular
+3 -3
View File
@@ -4,19 +4,19 @@
*/ */
function config_get(string $key, $default = null) { function config_get(string $key, $default = null) {
$row = db_one("SELECT value FROM config WHERE `key` = ?", [$key]); $row = db_one("SELECT value FROM config WHERE key = ?", [$key]);
return $row ? $row['value'] : $default; return $row ? $row['value'] : $default;
} }
function config_set(string $key, string $value): void { function config_set(string $key, string $value): void {
db_query( db_query(
"INSERT INTO config (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)", "INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[$key, $value] [$key, $value]
); );
} }
function config_all(): array { function config_all(): array {
$rows = db_all("SELECT `key`, `value` FROM config"); $rows = db_all("SELECT key, value FROM config");
$cfg = []; $cfg = [];
foreach ($rows as $row) { foreach ($rows as $row) {
$cfg[$row['key']] = $row['value']; $cfg[$row['key']] = $row['value'];
Executable → Regular
+22 -24
View File
@@ -1,25 +1,35 @@
<?php <?php
/** /**
* Database connection and helpers. * Database connection and helpers.
* Uses MySQL/MariaDB via PDO (Cloudron-compatible). * Uses SQLite via PDO — no external database server required.
*
* The database file path is configurable via the DATABASE_PATH env var.
* Defaults to data/protocol_droid.db relative to the project root.
*/ */
function db_path(): string {
$configured = getenv('DATABASE_PATH');
if ($configured) return $configured;
return __DIR__ . '/../data/protocol_droid.db';
}
function db(): PDO { function db(): PDO {
static $pdo = null; static $pdo = null;
if ($pdo === null) { if ($pdo === null) {
$host = getenv('MYSQL_HOST') ?: 'localhost'; $path = db_path();
$port = getenv('MYSQL_PORT') ?: '3306'; $dir = dirname($path);
$name = getenv('MYSQL_DATABASE') ?: 'protocol_droid'; if (!is_dir($dir)) {
$user = getenv('MYSQL_USER') ?: 'root'; mkdir($dir, 0775, true);
$pass = getenv('MYSQL_PASSWORD') ?: ''; }
$dsn = "sqlite:{$path}";
$dsn = "mysql:host={$host};port={$port};dbname={$name};charset=utf8mb4";
try { try {
$pdo = new PDO($dsn, $user, $pass, [ $pdo = new PDO($dsn, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_EMULATE_PREPARES => false,
]); ]);
// Enable foreign key enforcement (off by default in SQLite)
$pdo->exec('PRAGMA foreign_keys = ON');
} catch (PDOException $e) { } catch (PDOException $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['error' => 'Database connection failed']); echo json_encode(['error' => 'Database connection failed']);
@@ -32,19 +42,7 @@ function db(): PDO {
function db_init(): void { function db_init(): void {
$sql = file_get_contents(__DIR__ . '/../schema.sql'); $sql = file_get_contents(__DIR__ . '/../schema.sql');
if (!$sql) return; if (!$sql) return;
// Split on semicolons at end of statements (simple split — schema has no complex strings with semicolons) db()->exec($sql);
$statements = array_filter(array_map('trim', preg_split('/;\s*(?=\n|$)/', $sql)));
foreach ($statements as $stmt) {
if (empty($stmt)) continue;
try {
db()->exec($stmt);
} catch (PDOException $e) {
// Ignore "already exists" errors
if (strpos($e->getMessage(), 'already exists') === false) {
error_log("Schema init: " . $e->getMessage());
}
}
}
} }
function db_query(string $sql, array $params = []): PDOStatement { function db_query(string $sql, array $params = []): PDOStatement {
@@ -75,8 +73,8 @@ function db_insert(string $table, array $data): int {
function db_update(string $table, array $data, string $where, array $whereParams): int { function db_update(string $table, array $data, string $where, array $whereParams): int {
$set = implode(', ', array_map(fn($c) => "`$c` = ?", array_keys($data))); $set = implode(', ', array_map(fn($c) => "`$c` = ?", array_keys($data)));
$sql = "UPDATE {$table} SET {$set} WHERE {$where}"; $sql = "UPDATE {$table} SET {$set} WHERE {$where}";
db_query($sql, array_merge(array_values($data), $whereParams)); $stmt = db_query($sql, array_merge(array_values($data), $whereParams));
return db_query("SELECT ROW_COUNT()")->fetchColumn(); return $stmt->rowCount();
} }
function slugify(string $text): string { function slugify(string $text): string {
Executable → Regular
+32 -30
View File
@@ -169,15 +169,14 @@ if ($parts[0] === 'protocols') {
if (!$slug) { if (!$slug) {
// List protocols // List protocols
if ($method !== 'GET') respond(405, ['error' => 'Method not allowed']); if ($method === 'GET') {
$where = "WHERE p.visibility = 'public'"; $where = "WHERE p.visibility = 'public'";
$params = []; $params = [];
$tag = $_GET['tag'] ?? null; $tag = $_GET['tag'] ?? null;
if ($tag) { if ($tag) {
// Use JSON_CONTAINS for accurate tag matching (MySQL 5.7+) // Match tag within the JSON array string
$where .= " AND JSON_CONTAINS(p.tags, JSON_QUOTE(?))"; $where .= " AND EXISTS (SELECT 1 FROM json_each(p.tags) WHERE value = ?)";
$params[] = $tag; $params[] = $tag;
} }
@@ -194,6 +193,34 @@ if ($parts[0] === 'protocols') {
respond(200, array_map('format_protocol', $rows)); respond(200, array_map('format_protocol', $rows));
} }
// Create protocol
if ($method === 'POST') {
$user = require_member();
$data = json_body();
$new_slug = ensure_unique_slug($data['slug'] ?? slugify($data['title'] ?? 'untitled'));
$id = db_insert('protocols', [
'slug' => $new_slug,
'title' => substr($data['title'] ?? '', 0, 255),
'description' => $data['description'] ?? '',
'source' => $data['source'] ?? ('@' . $user['username']),
'source_url' => substr($data['source_url'] ?? '', 0, 500),
'tags' => json_encode(array_slice($data['tags'] ?? [], 0, 20)),
'forked_from' => $data['forked_from'] ?? null,
'image' => $data['image'] ?? null,
'steps' => json_encode(array_slice($data['steps'] ?? [], 0, 50)),
'outcome' => $data['outcome'] ?? '',
'practice' => $data['practice'] ?? '',
'author_id' => $user['id'],
'visibility' => validate_visibility($data['visibility'] ?? 'public'),
]);
$row = db_one("SELECT p.*, u.username AS author_username FROM protocols p LEFT JOIN users u ON p.author_id = u.id WHERE p.id = ?", [$id]);
respond(201, format_protocol($row));
}
respond(405, ['error' => 'Method not allowed']);
}
// YAML export // YAML export
if (($parts[2] ?? '') === 'yaml' && $method === 'GET') { if (($parts[2] ?? '') === 'yaml' && $method === 'GET') {
$row = db_one("SELECT * FROM protocols WHERE slug = ?", [$slug]); $row = db_one("SELECT * FROM protocols WHERE slug = ?", [$slug]);
@@ -236,31 +263,6 @@ if ($parts[0] === 'protocols') {
respond(200, format_protocol($row)); respond(200, format_protocol($row));
} }
// Create protocol
if ($method === 'POST') {
$user = require_member();
$data = json_body();
$new_slug = ensure_unique_slug($data['slug'] ?? slugify($data['title'] ?? 'untitled'));
$id = db_insert('protocols', [
'slug' => $new_slug,
'title' => substr($data['title'] ?? '', 0, 255),
'description' => $data['description'] ?? '',
'source' => $data['source'] ?? ('@' . $user['username']),
'source_url' => substr($data['source_url'] ?? '', 0, 500),
'tags' => json_encode(array_slice($data['tags'] ?? [], 0, 20)),
'forked_from' => $data['forked_from'] ?? null,
'image' => $data['image'] ?? null,
'steps' => json_encode(array_slice($data['steps'] ?? [], 0, 50)),
'outcome' => $data['outcome'] ?? '',
'practice' => $data['practice'] ?? '',
'author_id' => $user['id'],
'visibility' => validate_visibility($data['visibility'] ?? 'public'),
]);
$row = db_one("SELECT p.*, u.username AS author_username FROM protocols p LEFT JOIN users u ON p.author_id = u.id WHERE p.id = ?", [$id]);
respond(201, format_protocol($row));
}
// Update protocol — auth before existence check // Update protocol — auth before existence check
if ($method === 'PUT') { if ($method === 'PUT') {
$user = require_auth(); $user = require_auth();
@@ -459,7 +461,7 @@ if ($parts[0] === 'votes' && $method === 'POST') {
// Upsert vote // Upsert vote
db_query( db_query(
"INSERT INTO votes (user_id, protocol_slug, value, comment) VALUES (?, ?, ?, ?) "INSERT INTO votes (user_id, protocol_slug, value, comment) VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE value = VALUES(value), comment = VALUES(comment)", ON CONFLICT(user_id, protocol_slug) DO UPDATE SET value = excluded.value, comment = excluded.comment",
[$user['id'], $protocol_slug, $value, $comment] [$user['id'], $protocol_slug, $value, $comment]
); );
respond(200, ['ok' => true]); respond(200, ['ok' => true]);
Executable → Regular
+6 -4
View File
@@ -83,10 +83,12 @@ function yaml_array(array $arr): string {
*/ */
function parse_protocol_yaml(string $yaml_text): ?array { function parse_protocol_yaml(string $yaml_text): ?array {
if (function_exists('yaml_parse')) { if (function_exists('yaml_parse')) {
// Harden against PHP object injection via !php/object tags // Harden against PHP object injection via !php/object and !php/const tags.
$data = yaml_parse($yaml_text, -1, $ndocs, [ // The callbacks must be valid callables; null them out to neutralize deserialization.
'!php/object' => false, $neutralize = fn() => null;
'!php/const' => false, $data = yaml_parse($yaml_text, 0, $ndocs, [
'!php/object' => $neutralize,
'!php/const' => $neutralize,
]); ]);
if (!is_array($data)) return null; if (!is_array($data)) return null;
} else { } else {
+2 -6
View File
@@ -4,18 +4,14 @@
"author": "Media Economies Design Lab, CU Boulder", "author": "Media Economies Design Lab, CU Boulder",
"description": "A tool for authoring, sharing, and curating social protocols. Self-hostable and whitelabel-able.", "description": "A tool for authoring, sharing, and curating social protocols. Self-hostable and whitelabel-able.",
"tagline": "A commons of social protocols", "tagline": "A commons of social protocols",
"version": "0.1.0", "version": "0.3.0",
"healthCheckPath": "/api", "healthCheckPath": "/api",
"httpPort": 8000, "httpPort": 8000,
"manifestVersion": 2, "manifestVersion": 2,
"website": "https://medlab.host", "website": "https://medlab.host",
"contactEmail": "medlab@colorado.edu", "contactEmail": "medlab@colorado.edu",
"icon": "file://icon.png", "icon": "file://icon.png",
"addons": { "addons": {},
"mysql": {
"noDatabase": false
}
},
"tcpPorts": {}, "tcpPorts": {},
"udpPorts": {}, "udpPorts": {},
"capabilities": [], "capabilities": [],
Executable → Regular
+45 -32
View File
@@ -1,20 +1,20 @@
-- Protocol Droid — MySQL Schema -- Protocol Droid — SQLite Schema
-- Works with MySQL 5.7+ / MariaDB 10.3+ -- Works with SQLite 3.35+ (for ON CONFLICT support)
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY, id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(32) UNIQUE NOT NULL, username VARCHAR(32) UNIQUE NOT NULL,
display_name VARCHAR(128), display_name VARCHAR(128),
bio TEXT, bio TEXT,
password_hash VARCHAR(255), -- bcrypt; NULL if SSO-only password_hash VARCHAR(255), -- bcrypt; NULL if SSO-only
sso_provider VARCHAR(64), -- 'oidc', 'github', 'google', etc. sso_provider VARCHAR(64), -- 'oidc', 'github', 'google', etc.
sso_id VARCHAR(255), -- external identity id sso_id VARCHAR(255), -- external identity id
role ENUM('admin','member','viewer') DEFAULT 'member', role VARCHAR(10) DEFAULT 'member' CHECK (role IN ('admin','member','viewer')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; );
CREATE TABLE IF NOT EXISTS protocols ( CREATE TABLE IF NOT EXISTS protocols (
id INT AUTO_INCREMENT PRIMARY KEY, id INTEGER PRIMARY KEY AUTOINCREMENT,
slug VARCHAR(128) UNIQUE NOT NULL, slug VARCHAR(128) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
description TEXT, description TEXT,
@@ -26,56 +26,69 @@ CREATE TABLE IF NOT EXISTS protocols (
steps TEXT, -- JSON array of {headline, description} steps TEXT, -- JSON array of {headline, description}
outcome TEXT, outcome TEXT,
practice TEXT, practice TEXT,
author_id INT REFERENCES users(id), author_id INTEGER REFERENCES users(id),
visibility ENUM('public','private','unlisted') DEFAULT 'public', visibility VARCHAR(10) DEFAULT 'public' CHECK (visibility IN ('public','private','unlisted')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
INDEX idx_visibility (visibility), );
INDEX idx_author (author_id), CREATE INDEX IF NOT EXISTS idx_protocols_visibility ON protocols (visibility);
INDEX idx_forked (forked_from) CREATE INDEX IF NOT EXISTS idx_protocols_author ON protocols (author_id);
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE INDEX IF NOT EXISTS idx_protocols_forked ON protocols (forked_from);
-- Trigger to update updated_at on row modification
CREATE TRIGGER IF NOT EXISTS protocols_updated_at
AFTER UPDATE ON protocols
BEGIN
UPDATE protocols SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
CREATE TABLE IF NOT EXISTS collections ( CREATE TABLE IF NOT EXISTS collections (
id INT AUTO_INCREMENT PRIMARY KEY, id INTEGER PRIMARY KEY AUTOINCREMENT,
slug VARCHAR(128) UNIQUE NOT NULL, slug VARCHAR(128) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
description TEXT, description TEXT,
source VARCHAR(255), source VARCHAR(255),
image VARCHAR(500), image VARCHAR(500),
author_id INT REFERENCES users(id), author_id INTEGER REFERENCES users(id),
visibility ENUM('public','private','unlisted') DEFAULT 'public', visibility VARCHAR(10) DEFAULT 'public' CHECK (visibility IN ('public','private','unlisted')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
INDEX idx_visibility (visibility), );
INDEX idx_author (author_id) CREATE INDEX IF NOT EXISTS idx_collections_visibility ON collections (visibility);
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE INDEX IF NOT EXISTS idx_collections_author ON collections (author_id);
CREATE TRIGGER IF NOT EXISTS collections_updated_at
AFTER UPDATE ON collections
BEGIN
UPDATE collections SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
CREATE TABLE IF NOT EXISTS collection_items ( CREATE TABLE IF NOT EXISTS collection_items (
collection_id INT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
item_type ENUM('protocol','collection') NOT NULL, item_type VARCHAR(10) NOT NULL CHECK (item_type IN ('protocol','collection')),
item_slug VARCHAR(128) NOT NULL, item_slug VARCHAR(128) NOT NULL,
position INT DEFAULT 0, position INTEGER DEFAULT 0,
PRIMARY KEY (collection_id, item_type, item_slug) PRIMARY KEY (collection_id, item_type, item_slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; );
CREATE TABLE IF NOT EXISTS votes ( CREATE TABLE IF NOT EXISTS votes (
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
protocol_slug VARCHAR(128) NOT NULL, protocol_slug VARCHAR(128) NOT NULL,
value INT DEFAULT 1, -- +1 upvote, -1 downvote value INTEGER DEFAULT 1, -- +1 upvote, -1 downvote
comment TEXT, -- optional evaluation text comment TEXT, -- optional evaluation text
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, protocol_slug) PRIMARY KEY (user_id, protocol_slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; );
CREATE TABLE IF NOT EXISTS config ( CREATE TABLE IF NOT EXISTS config (
`key` VARCHAR(64) PRIMARY KEY, key VARCHAR(64) PRIMARY KEY,
`value` TEXT value TEXT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; );
-- Default config entries -- Default config entries
INSERT INTO config (`key`, `value`) VALUES INSERT INTO config (key, value) VALUES
('site_name', 'Protocol Droid'), ('site_name', 'Protocol Droid'),
('site_tagline', 'A commons of social protocols'), ('site_tagline', 'A commons of social protocols'),
('registration_enabled', 'true'), ('registration_enabled', 'true'),
('require_approval', 'false') ('require_approval', 'false')
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`); ON CONFLICT(key) DO UPDATE SET value = excluded.value;