local lud = require "ludweb"
local auth = require "auth"
--[[
common data representations:
time INTEGER - unix timestamp
color INTEGER - 24-bits RGB; MSB is red, LSB is blue
priority INTEGER - no bounds; highest number <-> highest priority
]]
local schema = [[
CREATE TABLE IF NOT EXISTS User (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nick TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
salt TEXT,
hash TEXT
);
CREATE TABLE IF NOT EXISTS Invite (
uuid TEXT PRIMARY KEY,
expire INTEGER NOT NULL,
user_id INTEGER REFERENCES User(id)
);
CREATE TABLE IF NOT EXISTS Project (
id INTEGER PRIMARY KEY AUTOINCREMENT,
time INTEGER NOT NULL,
name TEXT NOT NULL UNIQUE,
desc TEXT,
goal TEXT,
color TEXT,
priority INTEGER NOT NULL,
is_active INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS Membership (
proj_id INTEGER REFERENCES Project(id),
user_id INTEGER REFERENCES User(id),
PRIMARY KEY(proj_id, user_id)
);
CREATE TABLE IF NOT EXISTS State (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS Ticket (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proj_id INTEGER NOT NULL REFERENCES Project(id),
user_id INTEGER NOT NULL REFERENCES User(id),
state_id INTEGER NOT NULL REFERENCES State(id),
time INTEGER NOT NULL,
code INTEGER NOT NULL,
title TEXT NOT NULL,
desc TEXT,
priority INTEGER NOT NULL,
is_active INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS Block (
blocked INTEGER REFERENCES Ticket(id),
blocker INTEGER REFERENCES Ticket(id),
PRIMARY KEY(blocked, blocker)
);
CREATE TABLE IF NOT EXISTS Shift (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL REFERENCES Ticket(id),
old_stt_id INTEGER NOT NULL REFERENCES State(id),
new_stt_id INTEGER NOT NULL REFERENCES State(id),
user_id INTEGER NOT NULL REFERENCES User(id),
time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS Comment (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL REFERENCES Ticket(id),
user_id INTEGER NOT NULL REFERENCES User(id),
time INTEGER NOT NULL,
text TEXT NOT NULL
);
]]
local Model = {}
Model.__index = Model
function Model:create_tables()
self.db:execute_many(schema)
end
function Model:create_states()
self.db:execute[[
INSERT INTO State(name) VALUES
("backlog"), ("design"), ("progress"), ("review"), ("done");
]]
end
function Model:get_states()
return self.db:execute("SELECT * FROM State;")
end
function Model:get_indexed_states()
local query = "SELECT id, name FROM State;"
local states = {}
for _, state in ipairs(self.db:execute(query)) do
states[state.id] = state.name
end
return states
end
function Model:create_user(nick, name, password)
local salt = auth.get_salt()
local hash = auth.hash_pass(password, salt)
salt = auth.b64_enc(salt)
hash = auth.b64_enc(hash)
local query = "INSERT INTO User(nick, name, salt, hash) VALUES (?, ?, ?, ?);"
self.db:execute(query, nick, name, salt, hash)
end
function Model:get_user(nick)
return self.db:execute("SELECT * FROM User WHERE nick = ?;", nick)[1]
end
function Model:get_indexed_users()
local query = "SELECT id, nick, name FROM User;"
local users = {}
for _, user in ipairs(self.db:execute(query)) do
users[user.id] = {nick=user.nick, name=user.name}
end
return users
end
function Model:create_invite(user_id)
local uuid = auth.hex(auth.uuid4())
local expire = "unixepoch('now', '+2 days')"
local query = "INSERT INTO Invite(uuid, expire, user_id) VALUES (?, "..expire..", ?);"
self.db:execute(query, uuid, user_id)
return uuid
end
function Model:get_invites(user_id)
return self.db:execute("SELECT * FROM Invite WHERE user_id = ?;", user_id)
end
function Model:expire_invites()
self.db:execute("DELETE FROM Invite WHERE expire < unixepoch();")
end
function Model:use_invite(uuid)
self:expire_invites()
local inv = self.db:execute("SELECT * FROM Invite WHERE uuid = ?;", uuid)[1]
if inv ~= nil then
self.db:execute("DELETE FROM Invite WHERE uuid = ?;", uuid)
end
return inv ~= nil
end
function Model:del_invite(user_id, uuid)
self.db:execute("DELETE FROM Invite WHERE user_id = ? AND uuid = ?;", user_id, uuid)
end
function Model:create_project(user_id, name, desc, goal, color, priority)
local query = [[
INSERT INTO Project(time, name, desc, goal, color, priority, is_active)
VALUES (unixepoch(), ?, ?, ?, ?, ?, 1);
]]
self.db:execute(query, name, desc, goal, color, priority)
local proj_id = self.db:execute("SELECT id FROM Project WHERE name = ?;", name)[1].id
self.db:execute("INSERT INTO Membership(proj_id, user_id) VALUES (?, ?);", proj_id, user_id)
return proj_id
end
function Model:get_project(name)
return self.db:execute("SELECT * FROM Project WHERE name = ?;", name)[1]
end
function Model:get_user_project(user_id, proj_name)
return self.db:execute([[
SELECT * FROM Project JOIN Membership ON Project.id = Membership.proj_id
WHERE Membership.user_id = ? AND Project.name = ? ORDER BY priority DESC;
]], user_id, proj_name)[1]
end
function Model:get_user_projects(user_id)
return self.db:execute([[
SELECT * FROM Project JOIN Membership ON Project.id = Membership.proj_id
WHERE Membership.user_id = ? ORDER BY priority DESC;
]], user_id)
end
function Model:update_project(old_name, new_name, desc, goal, color, priority)
local query = [[
UPDATE Project SET name = ?, desc = ?, goal = ?, color = ?, priority = ?
WHERE name = ?;
]]
self.db:execute(query, new_name, desc, goal, color, priority, old_name)
end
function Model:del_project(proj_id)
self.db:execute("DELETE FROM Membership WHERE proj_id = ?;", proj_id)
self.db:execute("DELETE FROM Ticket WHERE proj_id = ?;", proj_id)
-- TODO: delete associated comments and shifts
self.db:execute("DELETE FROM Project WHERE id = ?;", proj_id)
end
-- generate new ticket code for a given project
function Model:get_next_code(proj_id)
local query = "SELECT code FROM Ticket WHERE proj_id = ? ORDER BY code DESC LIMIT 1;"
local last_ticket = self.db:execute(query, proj_id)[1]
local last_code
if last_ticket == nil then
last_code = 0
else
last_code = last_ticket.code
end
return last_code + 1
end
function Model:create_ticket(user_id, proj_id, title, desc, priority)
local code = self:get_next_code(proj_id)
self.db:execute([[
INSERT INTO Ticket(proj_id, user_id, state_id, time, code, title, desc, priority, is_active)
VALUES (?, ?, 1, unixepoch(), ?, ?, ?, ?, 1);
]], proj_id, user_id, code, title, desc, priority)
return code
end
function Model:get_ticket(proj_id, code)
local query = "SELECT * FROM Ticket WHERE proj_id = ? AND code = ?;"
return self.db:execute(query, proj_id, code)[1]
end
function Model:get_tickets(proj_id, state_id)
-- TODO: limit "done" tickets by age
local query = "SELECT * FROM Ticket WHERE proj_id = ? AND state_id = ?;"
return self.db:execute(query, proj_id, state_id)
end
function Model:update_ticket(tick_id, title, desc, priority)
local query = "UPDATE Ticket SET title = ?, desc = ?, priority = ? WHERE id = ?;"
self.db:execute(query, title, desc, priority, tick_id)
end
function Model:shift_ticket(user_id, ticket, state_id)
self.db:execute([[
INSERT INTO Shift(ticket_id, old_stt_id, new_stt_id, user_id, time)
VALUES (?, ?, ?, ?, unixepoch());
]], ticket.id, ticket.state_id, state_id, user_id)
self.db:execute("UPDATE Ticket SET state_id = ? WHERE id = ?;", state_id, ticket.id)
end
function Model:del_ticket(tick_id)
self.db:execute("DELETE FROM Ticket WHERE id = ?;", tick_id)
end
function Model:add_comment(user_id, ticket_id, text)
self.db:execute([[
INSERT INTO Comment(ticket_id, user_id, time, text)
VALUES (?, ?, unixepoch(), ?);
]], ticket_id, user_id, text)
end
function Model:get_comments(ticket_id)
return self.db:execute("SELECT * FROM Comment WHERE ticket_id = ?;", ticket_id)
end
-- return a list of columns ordered by state ID
-- each column is a list of cards ordered by time
-- column[0] contains extra info: `name`
-- each card is a ticket DB object with keys `proj_name` and `user_name` added
function Model:get_board(user_id, proj_id)
-- TODO: filter by proj_id if it's not nil
local query = [[
SELECT
Ticket.*,
User.name AS author_name,
Project.name AS proj_name
FROM
Membership
JOIN
Project ON Membership.proj_id = Project.id
JOIN
Ticket ON Project.id = Ticket.proj_id
JOIN
User ON Ticket.user_id = User.id
WHERE
Membership.user_id = ?;
]]
local tickets = self.db:execute(query, user_id)
local columns = {}
-- TODO: fill columns from DB data
return columns
end
function Model:close()
self.db:close()
end
local function open(path)
local self = setmetatable({path=path}, Model)
self.db = lud.sqlite.open(path)
return self
end
return {open=open}