Compare commits

...

2 Commits

Author SHA1 Message Date
Muyao CHEN
3d616bff50 db: finish sql commands
All checks were successful
Build and test / Build (push) Successful in 2m23s
2024-10-19 17:08:05 +02:00
Muyao CHEN
dac36db284 db: add more tables 2024-10-19 13:28:02 +02:00
14 changed files with 558 additions and 46 deletions

View File

@ -421,27 +421,28 @@ The following basic use cases are to be implemented at the first time.
- [X] A user signs up - [X] A user signs up
- [X] A user logs in - [X] A user logs in
- [X] A user lists their events (pagination) - [ ] A user lists their events (pagination)
- [X] A user sees the detail of an event (description, members, amount) - [ ] A user sees the detail of an event (description, members, amount)
- [] A user sees the expenses of an event (total amount, personal expenses, pagination) - [ ] A user sees the expenses of an event (total amount, personal expenses, pagination)
- [] A user sees the detail of an expense: (time, amount, payers, recipients) - [ ] A user sees the detail of an expense: (time, amount, payers, recipients)
- [] A user adds an expense - [ ] A user adds an expense
- [] A user updates/changes an expense (may handle some extra access control) - [ ] A user updates/changes an expense (may handle some extra access control)
- [] A user deletes an expense (may handle some extra access control) - [ ] A user can pay the debt to other members (just a special case of expense)
- [] A user restore a deleted expense - [ ] A user creates an event (and participate to it)
- [] A user can pay the debt to other members - [ ] A user updates the event info
- [X] A user creates an event (and participate to it) - [ ] A user invites another user by sending a mail with a token.
- [X] A user updates the event info - [ ] A user joins an event by accepting an invitation
- [X] A user invites another user by sending a mail with a token. - [ ] A user cannot see other user's information
- [X] A user joins an event by accepting an invitation - [ ] A user cannot see the events that they didn't participated in.
- [] ~A user quits an event (they cannot actually, but we can make as if they quitted)~
**No we can't quit!**
- [] A user cannot see other user's information
- [] A user cannot see the events that they didn't participated in.
For the second stage: For the second stage:
- [] A user can archive an event - [ ] A user can archive an event
- [ ] A user deletes an expense (may handle some extra access control)
- [ ] A user restore a deleted expense
- [ ] Audit log for expense updates/deletes
- [ ] ~A user quits an event (they cannot actually, but we can make as if they
quitted)~ **No we can't quit!**
With those functionalities, there will be an usable product. And then we can With those functionalities, there will be an usable product. And then we can
work on other aspects. For example: work on other aspects. For example:
@ -491,3 +492,30 @@ GROUP BY
o.id, o.first_name, o.last_name; o.id, o.first_name, o.last_name;
``` ```
### 2024/10/19
I don't plan to handle deletions at this first stage, but I note down what I
have thought of.
1. Just delete. But keep a replica at the front end of the object that we are
deleting. And propose an option to restore (so a new record is added to the DB)
2. Just delete, but wait. The request is sent to a queue with a timeout of
several seconds, if the user regrets, they can cancel the request. This can be
done on the front, but also on the back. I think it is better to do in on the
front-end.
3. Never deletes. But keep a state in the DB `deleted`. They will just be
ignored when counting.
4. Deletes when doing database cleanup. They lines deleted will be processed
when we cleanup the DB. And they will be definitely deleted at that time.
I can create a audit log table to log all the critical
changes in my `expense` table (update or delete).
Finished with the basic SQL commands. Learned a lot from SQL about `JOIN`,
aggregation and `CTE`. SQL itself has quite amount of things to learn, this
is on my future learning plan!
_I found it quite interesting that simply with SQL, we can simulate the most
business logic. It is a must-have competence for software design and
development._

View File

@ -0,0 +1,72 @@
-- name: InsertExpense :one
INSERT INTO "expense" (
created_at, updated_at, amount, currency, event_id, name, place
) VALUES ( $1, $2, $3, $4, $5, $6, $7 )
RETURNING *;
-- name: DeleteExpense :exec
DELETE FROM "expense" WHERE id = $1;
-- name: DeleteTransactionsOfExpenseID :exec
DELETE FROM "transaction" WHERE transaction.expense_id = $1;
-- name: UpdateExpenseByID :one
UPDATE "expense"
SET updated_at = $2, amount = $3, currency = $4, name = $5, place = $6
WHERE id = $1
RETURNING *;
-- name: ListExpensesByEventID :many
SELECT
ex.id, ex.created_at, ex.updated_at, ex.amount, ex.currency, ex.event_id,
ex.name, ex.place
FROM "expense" ex
JOIN "event" ev ON ev.id = ex.event_id
WHERE ev.id = $1;
-- name: ListExpensesByEventIDByUserID :many
SELECT
ex.id, ex.created_at, ex.updated_at, ex.amount, ex.currency, ex.event_id,
ex.name, ex.place
FROM "expense" ex
JOIN "event" ev ON ev.id = ex.event_id
WHERE ev.id = $1;
-- name: GetExpenseByID :one
WITH payer_transaction as (
SELECT pt.expense_id,
json_agg(json_build_object(
'payer_id', p.id,
'payer_first_name', p.first_name,
'payer_last_name', p.last_name,
'amount', pt.amount,
'currency', pt.currency
)) AS payments
FROM "transaction" pt
JOIN "user" p ON p.id = pt.user_id
WHERE pt.is_income = FALSE
GROUP BY pt.expense_id
), -- For each expense, aggregate payment info
recipient_transaction as (
SELECT rt.expense_id,
json_agg(json_build_object(
'recipient_id', p.id,
'recipient_first_name', p.first_name,
'recipient_last_name', p.last_name,
'amount', rt.amount,
'currency', rt.currency
)) AS benefits
FROM "transaction" rt
JOIN "user" p ON p.id = rt.user_id
WHERE rt.is_income = TRUE
GROUP BY rt.expense_id
) -- For each expense, aggregate benefits info
SELECT
ex.id, ex.created_at, ex.updated_at, ex.amount, ex.currency, ex.event_id,
ex.name, ex.place,
COALESCE(pt.payments, '[]') AS payments,
COALESCE(rt.benefits, '[]') AS benefits
FROM "expense" ex
LEFT JOIN "payer_transaction" pt ON pt.expense_id = ex.id
LEFT JOIN "recipient_transaction" rt ON rt.expense_id = ex.id
WHERE ex.id = $1;

View File

@ -0,0 +1,264 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: expense.sql
package sqlc
import (
"context"
"database/sql"
"encoding/json"
"time"
)
const deleteExpense = `-- name: DeleteExpense :exec
DELETE FROM "expense" WHERE id = $1
`
func (q *Queries) DeleteExpense(ctx context.Context, id int32) error {
_, err := q.db.ExecContext(ctx, deleteExpense, id)
return err
}
const deleteTransactionsOfExpenseID = `-- name: DeleteTransactionsOfExpenseID :exec
DELETE FROM "transaction" WHERE transaction.expense_id = $1
`
func (q *Queries) DeleteTransactionsOfExpenseID(ctx context.Context, expenseID int32) error {
_, err := q.db.ExecContext(ctx, deleteTransactionsOfExpenseID, expenseID)
return err
}
const getExpenseByID = `-- name: GetExpenseByID :one
WITH payer_transaction as (
SELECT pt.expense_id,
json_agg(json_build_object(
'payer_id', p.id,
'payer_first_name', p.first_name,
'payer_last_name', p.last_name,
'amount', pt.amount,
'currency', pt.currency
)) AS payments
FROM "transaction" pt
JOIN "user" p ON p.id = pt.user_id
WHERE pt.is_income = FALSE
GROUP BY pt.expense_id
), -- For each expense, aggregate payment info
recipient_transaction as (
SELECT rt.expense_id,
json_agg(json_build_object(
'recipient_id', p.id,
'recipient_first_name', p.first_name,
'recipient_last_name', p.last_name,
'amount', rt.amount,
'currency', rt.currency
)) AS benefits
FROM "transaction" rt
JOIN "user" p ON p.id = rt.user_id
WHERE rt.is_income = TRUE
GROUP BY rt.expense_id
) -- For each expense, aggregate benefits info
SELECT
ex.id, ex.created_at, ex.updated_at, ex.amount, ex.currency, ex.event_id,
ex.name, ex.place,
COALESCE(pt.payments, '[]') AS payments,
COALESCE(rt.benefits, '[]') AS benefits
FROM "expense" ex
LEFT JOIN "payer_transaction" pt ON pt.expense_id = ex.id
LEFT JOIN "recipient_transaction" rt ON rt.expense_id = ex.id
WHERE ex.id = $1
`
type GetExpenseByIDRow struct {
ID int32
CreatedAt time.Time
UpdatedAt time.Time
Amount int32
Currency string
EventID int32
Name sql.NullString
Place sql.NullString
Payments json.RawMessage
Benefits json.RawMessage
}
func (q *Queries) GetExpenseByID(ctx context.Context, id int32) (GetExpenseByIDRow, error) {
row := q.db.QueryRowContext(ctx, getExpenseByID, id)
var i GetExpenseByIDRow
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Amount,
&i.Currency,
&i.EventID,
&i.Name,
&i.Place,
&i.Payments,
&i.Benefits,
)
return i, err
}
const insertExpense = `-- name: InsertExpense :one
INSERT INTO "expense" (
created_at, updated_at, amount, currency, event_id, name, place
) VALUES ( $1, $2, $3, $4, $5, $6, $7 )
RETURNING id, created_at, updated_at, amount, currency, event_id, name, place
`
type InsertExpenseParams struct {
CreatedAt time.Time
UpdatedAt time.Time
Amount int32
Currency string
EventID int32
Name sql.NullString
Place sql.NullString
}
func (q *Queries) InsertExpense(ctx context.Context, arg InsertExpenseParams) (Expense, error) {
row := q.db.QueryRowContext(ctx, insertExpense,
arg.CreatedAt,
arg.UpdatedAt,
arg.Amount,
arg.Currency,
arg.EventID,
arg.Name,
arg.Place,
)
var i Expense
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Amount,
&i.Currency,
&i.EventID,
&i.Name,
&i.Place,
)
return i, err
}
const listExpensesByEventID = `-- name: ListExpensesByEventID :many
SELECT
ex.id, ex.created_at, ex.updated_at, ex.amount, ex.currency, ex.event_id,
ex.name, ex.place
FROM "expense" ex
JOIN "event" ev ON ev.id = ex.event_id
WHERE ev.id = $1
`
func (q *Queries) ListExpensesByEventID(ctx context.Context, id int32) ([]Expense, error) {
rows, err := q.db.QueryContext(ctx, listExpensesByEventID, id)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Expense
for rows.Next() {
var i Expense
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Amount,
&i.Currency,
&i.EventID,
&i.Name,
&i.Place,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listExpensesByEventIDByUserID = `-- name: ListExpensesByEventIDByUserID :many
SELECT
ex.id, ex.created_at, ex.updated_at, ex.amount, ex.currency, ex.event_id,
ex.name, ex.place
FROM "expense" ex
JOIN "event" ev ON ev.id = ex.event_id
WHERE ev.id = $1
`
func (q *Queries) ListExpensesByEventIDByUserID(ctx context.Context, id int32) ([]Expense, error) {
rows, err := q.db.QueryContext(ctx, listExpensesByEventIDByUserID, id)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Expense
for rows.Next() {
var i Expense
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Amount,
&i.Currency,
&i.EventID,
&i.Name,
&i.Place,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateExpenseByID = `-- name: UpdateExpenseByID :one
UPDATE "expense"
SET updated_at = $2, amount = $3, currency = $4, name = $5, place = $6
WHERE id = $1
RETURNING id, created_at, updated_at, amount, currency, event_id, name, place
`
type UpdateExpenseByIDParams struct {
ID int32
UpdatedAt time.Time
Amount int32
Currency string
Name sql.NullString
Place sql.NullString
}
func (q *Queries) UpdateExpenseByID(ctx context.Context, arg UpdateExpenseByIDParams) (Expense, error) {
row := q.db.QueryRowContext(ctx, updateExpenseByID,
arg.ID,
arg.UpdatedAt,
arg.Amount,
arg.Currency,
arg.Name,
arg.Place,
)
var i Expense
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Amount,
&i.Currency,
&i.EventID,
&i.Name,
&i.Place,
)
return i, err
}

View File

@ -27,6 +27,17 @@ type Event struct {
TotalAmount sql.NullInt32 TotalAmount sql.NullInt32
} }
type Expense struct {
ID int32
CreatedAt time.Time
UpdatedAt time.Time
Amount int32
Currency string
EventID int32
Name sql.NullString
Place sql.NullString
}
type Participation struct { type Participation struct {
ID int32 ID int32
UserID int32 UserID int32
@ -36,6 +47,17 @@ type Participation struct {
UpdatedAt time.Time UpdatedAt time.Time
} }
type Transaction struct {
ID int32
ExpenseID int32
UserID int32
Amount int32
Currency string
IsIncome bool
CreatedAt time.Time
UpdatedAt time.Time
}
type User struct { type User struct {
ID int32 ID int32
Email string Email string

View File

@ -0,0 +1,5 @@
-- name: InsertTransaction :exec
INSERT INTO "transaction" (
created_at, updated_at, amount, currency, expense_id, user_id, is_income
) VALUES ( $1, $2, $3, $4, $5, $6, $7 )
RETURNING *;

View File

@ -0,0 +1,41 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: transaction.sql
package sqlc
import (
"context"
"time"
)
const insertTransaction = `-- name: InsertTransaction :exec
INSERT INTO "transaction" (
created_at, updated_at, amount, currency, expense_id, user_id, is_income
) VALUES ( $1, $2, $3, $4, $5, $6, $7 )
RETURNING id, expense_id, user_id, amount, currency, is_income, created_at, updated_at
`
type InsertTransactionParams struct {
CreatedAt time.Time
UpdatedAt time.Time
Amount int32
Currency string
ExpenseID int32
UserID int32
IsIncome bool
}
func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionParams) error {
_, err := q.db.ExecContext(ctx, insertTransaction,
arg.CreatedAt,
arg.UpdatedAt,
arg.Amount,
arg.Currency,
arg.ExpenseID,
arg.UserID,
arg.IsIncome,
)
return err
}

View File

@ -24,16 +24,44 @@ package model
import "time" import "time"
// {{{ Requrest
type ExpenseRequest struct { type ExpenseRequest struct {
Amount Money `json:"money" binding:"required,number"` Amount Money `json:"money" binding:"required"`
PayerIDs []int `json:"payer_ids" binding:"required"` Payments []Payment `json:"payments" binding:"required"`
RecipientIDs []int `json:"recipient_ids" binding:"required"` Benefits []Benefit `json:"benefits" binding:"required"`
EventID int `json:"event_id" binding:"required"` EventID int `json:"event_id" binding:"required"`
Detail ExpenseDetail `json:"detail"` Detail ExpenseDetail `json:"detail"`
} }
// }}}
// {{{ Response
type ExpensesListResponse struct {
ID int `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Amount Money `json:"money"`
EventID int `json:"event_id"`
Detail ExpenseDetail `json:"detail"`
}
type ExpenseGetResponse Expense
// }}}
// {{{ Retrieved
type ExpensesListRetrieved ExpensesListResponse
// }}}
// {{{ Entity
type ExpenseEntity struct { type ExpenseEntity struct {
ID int ID int
CreatedAt time.Time
UpdatedAt time.Time
Amount int Amount int
Currency string Currency string
@ -42,30 +70,42 @@ type ExpenseEntity struct {
// ExpenseDetail // ExpenseDetail
Name string Name string
Place string Place string
CreatedAt time.Time
UpdatedAt time.Time
} }
// }}}
// {{{ Domain Models
type ExpenseDetail struct { type ExpenseDetail struct {
Name string `json:"name"` Name string `json:"name"`
Place string `json:"place"` Place string `json:"place"`
} }
type Expense struct { type Payment struct {
ID int PayerID int `json:"payer_id" binding:"required,number"`
PayerFirstName string `json:"payer_first_name"`
Amount Money PayerLastName string `json:"payer_last_name"`
Amount Money `json:"amount" binding:"required"`
// Lazy aggregate using Transaction join
PayerIDs []int
// Lazy aggregate using Transaction join
RecipientIDs []int
EventID int
Detail ExpenseDetail
CreatedAt time.Time
UpdatedAt time.Time
} }
type Benefit struct {
RecipientID int `json:"recipient_id" binding:"required,number"`
RecipientFirstName string `json:"recipient_first_name"`
RecipientLastName string `json:"recipient_last_name"`
Amount Money `json:"amount" binding:"required"`
}
type Expense struct {
ID int `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Amount Money `json:"money"`
EventID int `json:"event_id"`
Detail ExpenseDetail `json:"detail"`
Payments []Payment `json:"payments"`
Benefits []Benefit `json:"benefits"`
}
// }}}

View File

@ -24,18 +24,25 @@ package model
import "time" import "time"
// {{{ Entity
type TransactionEntity Transaction type TransactionEntity Transaction
// Transaction is the association between Expenses and Users // }}}
// {{{ Domain object
type Transaction struct { type Transaction struct {
ID int ID int
ExpenseID Expense ExpenseID int
UserID int UserID int
Amount int Amount int
Currency string Currency string
IsIncome bool IsIncome bool // To note that the direction of the money (payment or income)
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
// }}}
// Transaction is the association between Expenses and Users

View File

@ -0,0 +1,14 @@
CREATE TABLE "transaction" (
"id" serial NOT NULL,
PRIMARY KEY ("id"),
"expense_id" integer NOT NULL,
"user_id" integer NOT NULL,
"amount" integer NOT NULL,
"currency" character varying(255) NOT NULL,
"is_income" boolean NOT NULL DEFAULT FALSE,
"created_at" date NOT NULL,
"updated_at" date NOT NULL
);
ALTER TABLE "transaction"
ADD FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1 @@
DROP TABLE "expense";

View File

@ -0,0 +1,14 @@
CREATE TABLE "expense" (
"id" serial NOT NULL,
PRIMARY KEY ("id"),
"created_at" date NOT NULL,
"updated_at" date NOT NULL,
"amount" integer NOT NULL,
"currency" character varying NOT NULL,
"event_id" integer NOT NULL,
"name" character varying(255) NULL,
"place" character varying(1000) NULL
);
ALTER TABLE "expense"
ADD FOREIGN KEY ("event_id") REFERENCES "event" ("id") ON DELETE CASCADE ON UPDATE CASCADE

View File

@ -0,0 +1 @@
DROP TABLE transaction;

View File

@ -0,0 +1,2 @@
ALTER TABLE "transaction"
ADD FOREIGN KEY ("expense_id") REFERENCES "expense" ("id") ON DELETE CASCADE ON UPDATE CASCADE