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,
invited_by INTEGER REFERENCES User(id)
);
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 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,
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,
new_stt_id INTEGER NOT NULL,
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,
code 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_user(nick, name, password, uuid)
self:expire_invites()
local inv = self.db:execute("SELECT * FROM Invite WHERE uuid = ?;", uuid)[1]
if inv == nil then return false end
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, invited_by) VALUES (?, ?, ?, ?, ?);"
self.db:execute(query, nick, name, salt, hash, inv.user_id)
self.db:execute("DELETE FROM Invite WHERE uuid = ?;", uuid)
return true
end
function Model:get_user_count()
return self.db:execute("SELECT COUNT(*) as count FROM User;")[1].count
end
function Model:get_user(nick, full)
local user = self.db:execute("SELECT * FROM User WHERE nick = ?;", nick)[1]
if full and user ~= nil and user.invited_by ~= nil then
local inviter = self.db:execute(
"SELECT nick, name FROM User WHERE id = ?;", user.invited_by
)[1]
user.inviter_nick = inviter.nick
user.inviter_name = inviter.name
end
return user
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:get_invitees(user_id)
return self.db:execute("SELECT nick, name FROM User WHERE invited_by = ?;", user_id)
end
function Model:can_manage(user, other)
local parent_id = other.invited_by
while parent_id ~= nil do
if parent_id == user.id then
return true
end
other = self.db:execute("SELECT invited_by FROM User WHERE id = ?;", parent_id)[1]
parent_id = other.invited_by
end
return false
end
function Model:del_user(user_id)
self.db:execute("DELETE FROM Membership WHERE user_id = ?;", user_id)
self.db:execute("DELETE FROM User WHERE id = ?;", user_id)
end
function Model:create_invite(user_id)
local uuid = auth.hex(auth.uuid4())
local expire = "strftime('%s', '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 < strftime('%s');")
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 (strftime('%s'), ?, ?, ?, ?, ?, 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 is_active DESC, priority DESC;
]], user_id)
end
function Model:get_members(proj_id)
return self.db:execute([[
SELECT nick, name FROM User JOIN Membership ON User.id = Membership.user_id
WHERE Membership.proj_id = ? ORDER BY nick;
]], proj_id)
end
function Model:get_non_members(proj_id)
return self.db:execute([[
SELECT User.nick, User.name
FROM User
LEFT JOIN Membership ON
User.id = Membership.user_id AND Membership.proj_id = ?
WHERE Membership.user_id IS NULL
ORDER BY User.nick;
]], proj_id)
end
function Model:add_member(proj_id, nick)
local user = self:get_user(nick)
if user == nil then return false end
local query = "INSERT INTO Membership(proj_id, user_id) VALUES (?, ?);"
self.db:execute(query, proj_id, user.id)
return true
end
function Model:remove_member(proj_id, nick)
local user = self:get_user(nick)
if user == nil then return false end
local query = "DELETE FROM Membership WHERE proj_id = ? AND user_id = ?;"
self.db:execute(query, proj_id, user.id)
return true
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:archive_project(proj_id)
self.db:execute("UPDATE Project SET is_active = FALSE WHERE id = ?;", proj_id)
end
function Model:restore_project(proj_id)
self.db:execute("UPDATE Project SET is_active = TRUE WHERE id = ?;", proj_id)
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 code for a child object
-- ticket_code = get_next_ticket_code("Ticket", "proj_id", proj_id)
-- comment_code = get_next_ticket_code("Comment", "ticket_id", ticket_id)
function Model:get_next_code(class, parent_row, parent_id)
local query = "SELECT code FROM %s WHERE %s = ? ORDER BY code DESC LIMIT 1;"
query = query:format(class, parent_row)
local last_child = self.db:execute(query, parent_id)[1]
local last_code
if last_child == nil then
last_code = 0
else
last_code = last_child.code
end
return last_code + 1
end
function Model:create_ticket(user_id, proj_id, title, desc, priority)
local code = self:get_next_code("Ticket", "proj_id", proj_id)
self.db:execute([[
INSERT INTO Ticket(proj_id, user_id, state_id, time, code, title, desc, priority, is_active)
VALUES (?, ?, 1, strftime('%s'), ?, ?, ?, ?, 1);
]], proj_id, user_id, code, title, desc, priority)
return code
end
function Model:get_ticket(proj_id, code, full)
local query
if full then
query = [[
SELECT Ticket.*, User.nick AS author_nick, User.name AS author_name
FROM Ticket JOIN User ON Ticket.user_id = User.id
WHERE Ticket.proj_id = ? AND Ticket.code = ?;
]]
else
query = "SELECT * FROM Ticket WHERE proj_id = ? AND code = ?;"
end
return self.db:execute(query, proj_id, code)[1]
end
function Model:get_tickets(proj_id, state_id)
local query = "SELECT * FROM Ticket WHERE proj_id = ? AND state_id = ?;"
return self.db:execute(query, proj_id, state_id)
end
function Model:get_archived_tickets(proj_id)
local query = [[
SELECT * FROM Ticket WHERE proj_id = ? AND is_active = FALSE
ORDER BY state_id ASC, code DESC;
]]
local ticks = self.db:execute(query, proj_id)
for i = 1, #ticks do
ticks[i].state = self.states[ticks[i].state_id]
end
return ticks
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)
local query = "SELECT * FROM %s WHERE ticket_id = ? ORDER BY time DESC LIMIT 1;"
local last_comment = self.db:execute(query:format("Comment"), ticket.id)[1]
local last_shift = self.db:execute(query:format("Shift"), ticket.id)[1]
if last_comment ~= nil and last_shift ~= nil and last_shift.time < last_comment.time then
-- don't undo last shift if a comment has been added after it
last_shift = nil
end
if last_shift ~= nil and last_shift.old_stt_id == state_id then
-- we're undoing the last shift: delete it from DB
self.db:execute("DELETE FROM Shift WHERE id = ?;", last_shift.id)
else
-- we're doing a new shift: add it to DB
self.db:execute([[
INSERT INTO Shift(ticket_id, old_stt_id, new_stt_id, user_id, time)
VALUES (?, ?, ?, ?, strftime('%s'));
]], ticket.id, ticket.state_id, state_id, user_id)
end
self.db:execute("UPDATE Ticket SET state_id = ? WHERE id = ?;", state_id, ticket.id)
end
function Model:archive_ticket(tick_id)
self.db:execute("UPDATE Ticket SET is_active = FALSE WHERE id = ?;", tick_id)
end
function Model:restore_ticket(tick_id)
self.db:execute("UPDATE Ticket SET is_active = TRUE WHERE id = ?;", tick_id)
end
function Model:del_ticket(tick_id)
self.db:execute("DELETE FROM Ticket WHERE id = ?;", tick_id)
end
function Model:get_shifts(ticket_id, full)
local query
if full then
query = [[
SELECT Shift.*, User.nick AS author_nick, User.name AS author_name
FROM Shift JOIN User ON Shift.user_id = User.id
WHERE Shift.ticket_id = ? ORDER BY time ASC;
]]
else
query = "SELECT * FROM Shift WHERE ticket_id = ? ORDER BY time ASC;"
end
local shifts = self.db:execute(query, ticket_id)
for i = 1, #shifts do
shifts[i].old_stt_name = self.states[shifts[i].old_stt_id]
shifts[i].new_stt_name = self.states[shifts[i].new_stt_id]
end
return shifts
end
function Model:add_comment(user_id, ticket_id, text)
local code = self:get_next_code("Comment", "ticket_id", ticket_id)
self.db:execute([[
INSERT INTO Comment(ticket_id, user_id, time, code, text)
VALUES (?, ?, strftime('%s'), ?, ?);
]], ticket_id, user_id, code, text)
return code
end
function Model:get_comment(ticket_id, code)
local query = "SELECT * FROM Comment WHERE ticket_id = ? AND code = ?;"
return self.db:execute(query, ticket_id, code)[1]
end
function Model:get_comments(ticket_id, full)
local query
if full then
query = [[
SELECT Comment.*, User.nick AS author_nick, User.name AS author_name
FROM Comment JOIN User ON Comment.user_id = User.id
WHERE Comment.ticket_id = ? ORDER BY code ASC;
]]
else
query = "SELECT * FROM Comment WHERE ticket_id = ? ORDER BY code ASC;"
end
return self.db:execute(query, ticket_id)
end
function Model:update_comment(comm_id, text)
self.db:execute("UPDATE Comment SET text = ? WHERE id = ?;", text, comm_id)
end
function Model:del_comment(comm_id)
self.db:execute("DELETE FROM Comment WHERE id = ?;", comm_id)
end
-- return a list of events associated with a ticket sorted by time
-- currently there are two types of events: shifts and comments
function Model:get_events(ticket_id, full)
local events = {}
for _, ev in ipairs(self:get_shifts(ticket_id, full)) do
ev.type = "shift"
table.insert(events, ev)
end
for _, ev in ipairs(self:get_comments(ticket_id, full)) do
ev.type = "comment"
table.insert(events, ev)
end
table.sort(events, function (a, b) return a.time < b.time end)
return events
end
-- return a list of columns ordered by state ID
-- each column is a list of cards ordered by priority
-- each card is a ticket DB object with some keys added:
-- author_nick, author_name, proj_name, proj_color, proj_priority
function Model:get_board(user_id, proj_id)
local filter
if proj_id ~= nil then
filter = " AND Ticket.proj_id = ?"
else
filter = " AND Project.is_active"
end
local query = [[
SELECT
Ticket.*,
User.nick AS author_nick,
User.name AS author_name,
Project.name AS proj_name,
Project.color AS proj_color,
Project.priority AS proj_priority
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 = ?]]..filter..[[
AND Ticket.is_active
ORDER BY Ticket.priority DESC, proj_priority DESC, Ticket.code ASC;
]]
local tickets
if proj_id ~= nil then
tickets = self.db:execute(query, user_id, proj_id)
else
tickets = self.db:execute(query, user_id)
end
local columns = {}
for i = 1, #self.states do
table.insert(columns, {})
end
for i, ticket in ipairs(tickets) do
table.insert(columns[ticket.state_id], ticket)
end
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)
self.states = {"backlog", "design", "progress", "review", "done"}
return self
end
return {open=open}