Files
wxapp-guantong/backend/internal/service/service.go
T
2026-09-22 22:51:19 +08:00

982 lines
27 KiB
Go

package service
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/db"
"github.com/sirupsen/logrus"
)
type Service struct {
DB *sql.DB
Q *db.Queries
Log *logrus.Logger
}
type CategoryWithProjects struct {
db.ProjectCategory
Projects []db.Project `json:"projects"`
}
type TimeSlotView struct {
ID string `json:"id"`
StartTime string `json:"start_time"`
Status string `json:"status"`
}
type ScheduleDayWithSlots struct {
Date string `json:"date"`
Status string `json:"status"`
Slots []TimeSlotView `json:"slots"`
}
type BookingDetail struct {
Booking db.Booking `json:"booking"`
Project db.Project `json:"project"`
Day db.ScheduleDay `json:"day"`
Slot TimeSlotView `json:"slot"`
}
type CreateBookingRequest struct {
OpenID string `json:"openid"`
ProjectID string `json:"project_id"`
SlotID string `json:"slot_id"`
Date string `json:"date"`
StartTime string `json:"start_time"`
Phone string `json:"phone"`
}
type CreatePriceInquiryRequest struct {
OpenID string `json:"openid"`
Province string `json:"province"`
City string `json:"city"`
District string `json:"district"`
ProjectName string `json:"project_name"`
}
type CreateTimeSlotRequest struct {
ScheduleDayID string `json:"schedule_day_id"`
StartTime string `json:"start_time"`
Status string `json:"status"`
}
type UpdateTimeSlotRequest struct {
StartTime string `json:"start_time"`
Status string `json:"status"`
}
type SessionResult struct {
User db.User `json:"user"`
SessionID string `json:"session_id"`
}
const (
defaultSlotLengthMinutes int64 = 60
priceInquiryShanghaiTimeMigrationKey = "migration.price_inquiries.asia_shanghai.v1"
)
var priceInquiryProjects = []string{
"牙齿矫正",
"牙齿种植",
"牙齿修复",
"牙齿治疗",
"全瓷牙种植",
"牙齿美白",
"正颌",
"瓷贴面",
"美容冠",
"牙齿清洁",
}
func New(database *sql.DB, logger *logrus.Logger) *Service {
return &Service{
DB: database,
Q: db.New(database),
Log: logger,
}
}
func ApplySchema(ctx context.Context, database *sql.DB, schemaPath string) error {
schema, err := os.ReadFile(schemaPath)
if err != nil {
return fmt.Errorf("read schema %s: %w", schemaPath, err)
}
if err := dedupeUserPhones(ctx, database); err != nil {
return fmt.Errorf("dedupe user phones: %w", err)
}
if err := ensurePriceInquiryColumns(ctx, database); err != nil {
return fmt.Errorf("ensure price inquiry columns: %w", err)
}
if err := dedupePriceInquiries(ctx, database); err != nil {
return fmt.Errorf("dedupe price inquiries: %w", err)
}
if _, err := database.ExecContext(ctx, string(schema)); err != nil {
return fmt.Errorf("apply schema: %w", err)
}
if err := ensureSchemaColumns(ctx, database); err != nil {
return fmt.Errorf("ensure schema columns: %w", err)
}
if err := migratePriceInquiryTimesToShanghai(ctx, database); err != nil {
return fmt.Errorf("migrate price inquiry times to Asia/Shanghai: %w", err)
}
if err := dropLegacyConfigColumns(ctx, database); err != nil {
return fmt.Errorf("drop legacy config columns: %w", err)
}
if err := normalizeScheduleStatuses(ctx, database); err != nil {
return fmt.Errorf("normalize schedule statuses: %w", err)
}
return nil
}
func dedupePriceInquiries(ctx context.Context, database *sql.DB) error {
exists, err := columnExists(ctx, database, "price_inquiries", "phone")
if err != nil || !exists {
return err
}
_, err = database.ExecContext(ctx, `
DELETE FROM price_inquiries
WHERE id IN (
SELECT id
FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY phone
ORDER BY last_submitted_at DESC, updated_at DESC, created_at DESC, id DESC
) AS duplicate_rank
FROM price_inquiries
)
WHERE duplicate_rank > 1
)`)
return err
}
func dedupeUserPhones(ctx context.Context, database *sql.DB) error {
exists, err := columnExists(ctx, database, "users", "phone")
if err != nil || !exists {
return err
}
rows, err := database.QueryContext(ctx, `
SELECT id, phone
FROM users
WHERE phone IS NOT NULL AND TRIM(phone) <> ''
ORDER BY phone ASC, updated_at DESC, created_at DESC, id DESC`)
if err != nil {
return err
}
defer rows.Close()
seen := make(map[string]struct{})
duplicateIDs := make([]string, 0)
for rows.Next() {
var (
id string
phone string
)
if err := rows.Scan(&id, &phone); err != nil {
return err
}
if _, ok := seen[phone]; ok {
duplicateIDs = append(duplicateIDs, id)
continue
}
seen[phone] = struct{}{}
}
if err := rows.Err(); err != nil {
return err
}
if len(duplicateIDs) == 0 {
return nil
}
tx, err := database.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, id := range duplicateIDs {
if _, err := tx.ExecContext(ctx, `
UPDATE users
SET phone = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`, id); err != nil {
return err
}
}
return tx.Commit()
}
func ensurePriceInquiryColumns(ctx context.Context, database *sql.DB) error {
exists, err := tableExists(ctx, database, "price_inquiries")
if err != nil || !exists {
return err
}
if err := addColumnIfMissing(ctx, database, "price_inquiries", "created_at", "created_at TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := addColumnIfMissing(ctx, database, "price_inquiries", "updated_at", "updated_at TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := addColumnIfMissing(ctx, database, "price_inquiries", "remark", "remark TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := addColumnIfMissing(ctx, database, "price_inquiries", "last_submitted_at", "last_submitted_at TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
statements := []string{
"UPDATE price_inquiries SET created_at = CURRENT_TIMESTAMP WHERE TRIM(created_at) = ''",
"UPDATE price_inquiries SET updated_at = created_at WHERE TRIM(updated_at) = ''",
"UPDATE price_inquiries SET last_submitted_at = created_at WHERE TRIM(last_submitted_at) = ''",
}
for _, statement := range statements {
if _, err := database.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}
func migratePriceInquiryTimesToShanghai(ctx context.Context, database *sql.DB) error {
tx, err := database.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var applied bool
if err := tx.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM app_config WHERE config_key = ?)`, priceInquiryShanghaiTimeMigrationKey).Scan(&applied); err != nil {
return err
}
if applied {
return nil
}
if _, err := tx.ExecContext(ctx, `
UPDATE price_inquiries
SET created_at = COALESCE(STRFTIME('%Y-%m-%d %H:%M:%f', created_at, '+8 hours'), created_at),
updated_at = COALESCE(STRFTIME('%Y-%m-%d %H:%M:%f', updated_at, '+8 hours'), updated_at),
last_submitted_at = COALESCE(STRFTIME('%Y-%m-%d %H:%M:%f', last_submitted_at, '+8 hours'), last_submitted_at)`); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO app_config (config_key, config_value) VALUES (?, 'applied')`, priceInquiryShanghaiTimeMigrationKey); err != nil {
return err
}
return tx.Commit()
}
func ensureSchemaColumns(ctx context.Context, database *sql.DB) error {
if err := addColumnIfMissing(ctx, database, "projects", "image", "image TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
return ensurePriceInquiryColumns(ctx, database)
}
func addColumnIfMissing(ctx context.Context, database *sql.DB, table, column, definition string) error {
exists, err := columnExists(ctx, database, table, column)
if err != nil || exists {
return err
}
_, err = database.ExecContext(ctx, "ALTER TABLE "+table+" ADD COLUMN "+definition)
return err
}
func tableExists(ctx context.Context, database *sql.DB, table string) (bool, error) {
var name string
err := database.QueryRowContext(ctx, `
SELECT name
FROM sqlite_master
WHERE type = 'table' AND name = ?`, table).Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
func dropLegacyConfigColumns(ctx context.Context, database *sql.DB) error {
columns := []struct {
table string
column string
}{
{table: "project_categories", column: "icon"},
{table: "projects", column: "duration_minutes"},
{table: "projects", column: "notice"},
}
for _, item := range columns {
exists, err := columnExists(ctx, database, item.table, item.column)
if err != nil {
return err
}
if !exists {
continue
}
if _, err := database.ExecContext(ctx, "ALTER TABLE "+item.table+" DROP COLUMN "+item.column); err != nil {
return err
}
}
return nil
}
func columnExists(ctx context.Context, database *sql.DB, table, column string) (bool, error) {
rows, err := database.QueryContext(ctx, "PRAGMA table_info("+table+")")
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var (
cid int64
name string
columnType string
notNull int64
defaultVal sql.NullString
pk int64
)
if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultVal, &pk); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}
func normalizeScheduleStatuses(ctx context.Context, database *sql.DB) error {
statements := []string{
"UPDATE schedule_days SET status = 'available', updated_at = CURRENT_TIMESTAMP WHERE status <> 'available'",
"UPDATE time_slots SET status = 'available', updated_at = CURRENT_TIMESTAMP WHERE status <> 'available'",
}
for _, statement := range statements {
if _, err := database.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}
func EnsureDatabaseDir(path string) error {
if strings.HasPrefix(path, "file:") || path == ":memory:" {
return nil
}
dir := filepath.Dir(path)
if dir == "." || dir == "" {
return nil
}
return os.MkdirAll(dir, 0o755)
}
func (s *Service) Seed(ctx context.Context) error {
count, err := s.Q.CountCategories(ctx)
if err != nil {
return err
}
if count > 0 {
return nil
}
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
q := s.Q.WithTx(tx)
categories := []db.CreateCategoryParams{
{ID: "cat_daily", Name: "日常护理", Sort: 10, Status: "active"},
{ID: "cat_clean", Name: "深层清洁", Sort: 20, Status: "active"},
{ID: "cat_white", Name: "美白亮齿", Sort: 30, Status: "active"},
{ID: "cat_breath", Name: "口气清新", Sort: 40, Status: "active"},
{ID: "cat_child", Name: "儿童护理", Sort: 50, Status: "active"},
}
for _, c := range categories {
if _, err := q.CreateCategory(ctx, c); err != nil {
return err
}
}
projects := []db.CreateProjectParams{
defaultProject("project_fresh_clean", "cat_daily", "清新洁牙护理", "温和清洁 · 清新口气", "¥398", 10),
defaultProject("project_deep_clean", "cat_clean", "深层洁牙护理", "深层清洁 · 呵护牙龈", "¥598", 20),
defaultProject("project_light_care", "cat_daily", "舒缓护理", "舒缓敏感 · 轻柔呵护", "¥498", 30),
defaultProject("project_bright_white", "cat_white", "亮白护理", "提亮美白 · 光泽自信", "¥698", 40),
defaultProject("project_breath_fresh", "cat_breath", "口气清新护理", "清新口气 · 轻松交流", "到店咨询", 50),
defaultProject("project_child_care", "cat_child", "儿童口腔护理", "轻松陪伴 · 温和体验", "¥298", 60),
}
for _, p := range projects {
if _, err := q.CreateProject(ctx, p); err != nil {
return err
}
}
base := time.Now().AddDate(0, 0, 1)
slotStarts := []string{"09:30", "10:30", "13:00", "14:00", "18:00", "19:00", "20:00"}
for _, project := range projects {
for i := 0; i < 21; i++ {
day := base.AddDate(0, 0, i)
date := day.Format("2006-01-02")
status := "available"
note := ""
dayID := staticScheduleDayID(project.ID, date)
createdDay, err := q.CreateScheduleDay(ctx, db.CreateScheduleDayParams{
ID: dayID, ProjectID: project.ID, Date: date, Status: status, Note: note,
})
if err != nil {
return err
}
for _, start := range slotStarts {
end := addMinutes(start, defaultSlotLengthMinutes)
slotStatus := status
_, err := q.CreateTimeSlot(ctx, db.CreateTimeSlotParams{
ID: staticSlotID(createdDay.ID, start),
ScheduleDayID: createdDay.ID,
StartTime: start,
EndTime: end,
Status: slotStatus,
})
if err != nil {
return err
}
}
}
}
return tx.Commit()
}
func addMinutes(hhmm string, minutes int64) string {
t, err := time.Parse("15:04", hhmm)
if err != nil {
return hhmm
}
return t.Add(time.Duration(minutes) * time.Minute).Format("15:04")
}
func defaultProject(id, categoryID, name, subtitle, priceText string, sort int64) db.CreateProjectParams {
return db.CreateProjectParams{
ID: id,
CategoryID: categoryID,
Name: name,
Subtitle: subtitle,
PriceText: priceText,
Status: "active",
Sort: sort,
}
}
func staticScheduleDayID(projectID, date string) string {
return "day_" + projectID + "_" + strings.ReplaceAll(date, "-", "")
}
func staticSlotID(scheduleDayID, startTime string) string {
return "slot_" + scheduleDayID + "_" + strings.ReplaceAll(startTime, ":", "")
}
func (s *Service) CategoriesWithProjects(ctx context.Context, admin bool) ([]CategoryWithProjects, error) {
var (
categories []db.ProjectCategory
projects []db.Project
err error
)
if admin {
categories, err = s.Q.ListCategories(ctx)
if err != nil {
return nil, err
}
projects, err = s.Q.ListProjects(ctx)
} else {
categories, err = s.Q.ListActiveCategories(ctx)
if err != nil {
return nil, err
}
projects, err = s.Q.ListActiveProjects(ctx)
}
if err != nil {
return nil, err
}
grouped := make(map[string][]db.Project)
for _, project := range projects {
grouped[project.CategoryID] = append(grouped[project.CategoryID], project)
}
result := make([]CategoryWithProjects, 0, len(categories))
for _, category := range categories {
result = append(result, CategoryWithProjects{
ProjectCategory: category,
Projects: grouped[category.ID],
})
}
return result, nil
}
func (s *Service) DeleteProject(ctx context.Context, projectID string) error {
projectID = strings.TrimSpace(projectID)
if projectID == "" {
return errors.New("项目 ID 不能为空")
}
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
qtx := s.Q.WithTx(tx)
if _, err := qtx.GetProject(ctx, projectID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return errors.New("项目不存在,请刷新后重试")
}
return err
}
bookingCount, err := qtx.CountBookingsByProject(ctx, projectID)
if err != nil {
return err
}
if bookingCount > 0 {
return errors.New("项目已有预约记录,请先下架项目以保留历史预约")
}
if err := qtx.DeleteTimeSlotsByProject(ctx, projectID); err != nil {
return err
}
if err := qtx.DeleteScheduleDaysByProject(ctx, projectID); err != nil {
return err
}
if err := qtx.DeleteProject(ctx, projectID); err != nil {
return err
}
return tx.Commit()
}
func (s *Service) ScheduleDays(ctx context.Context, projectID, month string) ([]ScheduleDayWithSlots, error) {
if strings.TrimSpace(projectID) == "" {
return nil, errors.New("projectId is required")
}
if month == "" {
month = time.Now().Format("2006-01")
}
days, err := s.Q.ListScheduleDaysByProjectMonth(ctx, db.ListScheduleDaysByProjectMonthParams{
ProjectID: projectID,
Month: sql.NullString{String: month, Valid: true},
})
if err != nil {
return nil, err
}
result := make([]ScheduleDayWithSlots, 0, len(days))
for _, day := range days {
slots, err := s.Q.ListTimeSlotsByDay(ctx, day.ID)
if err != nil {
return nil, err
}
slotViews := make([]TimeSlotView, 0, len(slots))
for _, slot := range slots {
slotViews = append(slotViews, toSlotView(slot))
}
result = append(result, ScheduleDayWithSlots{
Date: day.Date, Status: day.Status, Slots: slotViews,
})
}
return result, nil
}
func (s *Service) Login(ctx context.Context, code string) (SessionResult, error) {
code = strings.TrimSpace(code)
if code == "" {
code = "demo"
}
openid := "demo_openid_" + code
user, err := s.Q.GetUserByOpenID(ctx, openid)
if err == nil {
user, err = s.Q.TouchUserLogin(ctx, openid)
return SessionResult{User: user, SessionID: newID("session")}, err
}
if !errors.Is(err, sql.ErrNoRows) {
return SessionResult{}, err
}
user, err = s.Q.CreateUser(ctx, db.CreateUserParams{
ID: newID("user"), Openid: openid,
Unionid: sql.NullString{}, Phone: sql.NullString{},
})
return SessionResult{User: user, SessionID: newID("session")}, err
}
func (s *Service) BindPhone(ctx context.Context, openid, phone string) (db.User, error) {
phone = strings.TrimSpace(phone)
if phone == "" {
return db.User{}, errors.New("phone is required")
}
openid = strings.TrimSpace(openid)
if openid == "" {
openid = "demo_openid_demo"
}
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return db.User{}, err
}
defer tx.Rollback()
qtx := s.Q.WithTx(tx)
if _, err := tx.ExecContext(ctx, `
UPDATE users
SET phone = NULL, updated_at = CURRENT_TIMESTAMP
WHERE phone = ? AND openid <> ?`, phone, openid); err != nil {
return db.User{}, err
}
user, err := qtx.GetUserByOpenID(ctx, openid)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return db.User{}, err
}
user, err = qtx.CreateUser(ctx, db.CreateUserParams{
ID: newID("user"),
Openid: openid,
Unionid: sql.NullString{},
Phone: sql.NullString{String: phone, Valid: true},
})
if err != nil {
return db.User{}, err
}
if err := tx.Commit(); err != nil {
return db.User{}, err
}
return user, nil
}
user, err = qtx.BindUserPhone(ctx, db.BindUserPhoneParams{
ID: user.ID,
Phone: sql.NullString{String: phone, Valid: true},
})
if err != nil {
return db.User{}, err
}
if err := tx.Commit(); err != nil {
return db.User{}, err
}
return user, nil
}
func (s *Service) CreatePriceInquiry(ctx context.Context, req CreatePriceInquiryRequest) (db.PriceInquiry, error) {
province := strings.TrimSpace(req.Province)
city := strings.TrimSpace(req.City)
district := strings.TrimSpace(req.District)
projectName := strings.TrimSpace(req.ProjectName)
openid := strings.TrimSpace(req.OpenID)
if province == "" || city == "" || district == "" {
return db.PriceInquiry{}, errors.New("province, city and district are required")
}
if !isPriceInquiryProject(projectName) {
return db.PriceInquiry{}, errors.New("unsupported price inquiry project")
}
if openid == "" {
return db.PriceInquiry{}, errors.New("openid is required")
}
user, err := s.Q.GetUserByOpenID(ctx, openid)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return db.PriceInquiry{}, errors.New("phone authorization is required")
}
return db.PriceInquiry{}, err
}
phone := nullString(user.Phone)
if phone == "" {
return db.PriceInquiry{}, errors.New("phone authorization is required")
}
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return db.PriceInquiry{}, fmt.Errorf("begin price inquiry transaction: %w", err)
}
defer tx.Rollback()
qtx := s.Q.WithTx(tx)
inquiry, err := qtx.UpsertPriceInquiry(ctx, db.UpsertPriceInquiryParams{
ID: newID("price_inquiry"),
UserID: sql.NullString{String: user.ID, Valid: true},
Phone: phone,
Province: province,
City: city,
District: district,
ProjectName: projectName,
Status: "pending",
})
if err != nil {
return db.PriceInquiry{}, err
}
if err := tx.Commit(); err != nil {
return db.PriceInquiry{}, fmt.Errorf("commit price inquiry transaction: %w", err)
}
return inquiry, nil
}
func isPriceInquiryProject(name string) bool {
for _, project := range priceInquiryProjects {
if name == project {
return true
}
}
return false
}
func (s *Service) CreateBooking(ctx context.Context, req CreateBookingRequest) (BookingDetail, error) {
projectID := strings.TrimSpace(req.ProjectID)
phone := strings.TrimSpace(req.Phone)
if projectID == "" {
return BookingDetail{}, errors.New("projectId is required")
}
if phone == "" {
return BookingDetail{}, errors.New("phone is required")
}
req.ProjectID = projectID
req.Phone = phone
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return BookingDetail{}, err
}
defer tx.Rollback()
qtx := s.Q.WithTx(tx)
day, slot, err := ensureBookingSlot(ctx, qtx, req)
if err != nil {
return BookingDetail{}, err
}
userID := sql.NullString{}
if req.OpenID != "" {
if user, err := qtx.GetUserByOpenID(ctx, req.OpenID); err == nil {
userID = sql.NullString{String: user.ID, Valid: true}
}
}
booking, err := qtx.CreateBooking(ctx, db.CreateBookingParams{
ID: newID("booking"),
UserID: userID,
ProjectID: projectID,
SlotID: slot.ID,
Phone: phone,
Status: "pending",
Remark: "",
ConfirmToken: newID("confirm"),
})
if err != nil {
return BookingDetail{}, err
}
if err := tx.Commit(); err != nil {
return BookingDetail{}, err
}
project, err := s.Q.GetProject(ctx, projectID)
if err != nil {
return BookingDetail{}, err
}
return BookingDetail{
Booking: booking,
Project: project,
Day: day,
Slot: toSlotView(slot),
}, nil
}
func ensureBookingSlot(ctx context.Context, q *db.Queries, req CreateBookingRequest) (db.ScheduleDay, db.TimeSlot, error) {
projectID := strings.TrimSpace(req.ProjectID)
slotID := strings.TrimSpace(req.SlotID)
if slotID != "" {
slot, err := q.GetTimeSlot(ctx, slotID)
if err == nil {
day, err := q.GetScheduleDay(ctx, slot.ScheduleDayID)
if err != nil {
return db.ScheduleDay{}, db.TimeSlot{}, err
}
if day.ProjectID != projectID {
return db.ScheduleDay{}, db.TimeSlot{}, errors.New("slot does not belong to project")
}
return day, slot, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return db.ScheduleDay{}, db.TimeSlot{}, err
}
}
date := strings.TrimSpace(req.Date)
startTime := strings.TrimSpace(req.StartTime)
if date == "" || startTime == "" {
return db.ScheduleDay{}, db.TimeSlot{}, errors.New("date and start_time are required")
}
dayID := staticScheduleDayID(projectID, date)
day, err := q.GetScheduleDay(ctx, dayID)
if errors.Is(err, sql.ErrNoRows) {
day, err = q.CreateScheduleDay(ctx, db.CreateScheduleDayParams{
ID: dayID,
ProjectID: projectID,
Date: date,
Status: "available",
Note: "",
})
}
if err != nil {
return db.ScheduleDay{}, db.TimeSlot{}, err
}
slotID = staticSlotID(day.ID, startTime)
slot, err := q.GetTimeSlot(ctx, slotID)
if errors.Is(err, sql.ErrNoRows) {
slot, err = q.CreateTimeSlot(ctx, db.CreateTimeSlotParams{
ID: slotID,
ScheduleDayID: day.ID,
StartTime: startTime,
EndTime: addMinutes(startTime, defaultSlotLengthMinutes),
Status: "available",
})
}
if err != nil {
return db.ScheduleDay{}, db.TimeSlot{}, err
}
return day, slot, nil
}
func (s *Service) BookingDetail(ctx context.Context, booking db.Booking) (BookingDetail, error) {
project, err := s.Q.GetProject(ctx, booking.ProjectID)
if err != nil {
return BookingDetail{}, err
}
slot, err := s.Q.GetTimeSlot(ctx, booking.SlotID)
if err != nil {
return BookingDetail{}, err
}
day, err := s.Q.GetScheduleDay(ctx, slot.ScheduleDayID)
if err != nil {
return BookingDetail{}, err
}
return BookingDetail{Booking: booking, Project: project, Day: day, Slot: toSlotView(slot)}, nil
}
func (s *Service) ListBookingDetailsByPhone(ctx context.Context, phone string) ([]BookingDetail, error) {
bookings, err := s.Q.ListBookingsByPhone(ctx, phone)
if err != nil {
return nil, err
}
details := make([]BookingDetail, 0, len(bookings))
for _, booking := range bookings {
detail, err := s.BookingDetail(ctx, booking)
if err != nil {
return nil, err
}
details = append(details, detail)
}
return details, nil
}
func (s *Service) CancelBooking(ctx context.Context, id, phone string) (BookingDetail, error) {
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return BookingDetail{}, err
}
defer tx.Rollback()
qtx := s.Q.WithTx(tx)
booking, err := qtx.CancelBooking(ctx, db.CancelBookingParams{ID: id, Phone: phone})
if err != nil {
return BookingDetail{}, err
}
if err := tx.Commit(); err != nil {
return BookingDetail{}, err
}
return s.BookingDetail(ctx, booking)
}
func (s *Service) AdminListBookingDetails(ctx context.Context, status string, limit, offset int64) ([]BookingDetail, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
var (
bookings []db.Booking
err error
)
if status != "" {
bookings, err = s.Q.ListBookingsByStatus(ctx, db.ListBookingsByStatusParams{Status: status, Limit: limit, Offset: offset})
} else {
bookings, err = s.Q.ListBookings(ctx, db.ListBookingsParams{Limit: limit, Offset: offset})
}
if err != nil {
return nil, err
}
details := make([]BookingDetail, 0, len(bookings))
for _, booking := range bookings {
detail, err := s.BookingDetail(ctx, booking)
if err != nil {
return nil, err
}
details = append(details, detail)
}
return details, nil
}
func (s *Service) CreateTimeSlot(ctx context.Context, req CreateTimeSlotRequest) (TimeSlotView, error) {
if strings.TrimSpace(req.ScheduleDayID) == "" || strings.TrimSpace(req.StartTime) == "" {
return TimeSlotView{}, errors.New("scheduleDayId and startTime are required")
}
if req.Status == "" {
req.Status = "available"
}
endTime, err := s.slotEndTime(ctx, req.ScheduleDayID, req.StartTime)
if err != nil {
return TimeSlotView{}, err
}
slotID := "slot_" + req.ScheduleDayID + "_" + strings.ReplaceAll(req.StartTime, ":", "")
slot, err := s.Q.CreateTimeSlot(ctx, db.CreateTimeSlotParams{
ID: slotID,
ScheduleDayID: req.ScheduleDayID,
StartTime: req.StartTime,
EndTime: endTime,
Status: req.Status,
})
if err != nil {
return TimeSlotView{}, err
}
return toSlotView(slot), nil
}
func (s *Service) UpdateTimeSlot(ctx context.Context, id string, req UpdateTimeSlotRequest) (TimeSlotView, error) {
if strings.TrimSpace(id) == "" {
return TimeSlotView{}, errors.New("slot id is required")
}
slot, err := s.Q.GetTimeSlot(ctx, id)
if err != nil {
return TimeSlotView{}, err
}
if req.StartTime == "" {
req.StartTime = slot.StartTime
}
if req.Status == "" {
req.Status = slot.Status
}
endTime, err := s.slotEndTime(ctx, slot.ScheduleDayID, req.StartTime)
if err != nil {
return TimeSlotView{}, err
}
updated, err := s.Q.UpdateTimeSlot(ctx, db.UpdateTimeSlotParams{
ID: id,
StartTime: req.StartTime,
EndTime: endTime,
Status: req.Status,
})
if err != nil {
return TimeSlotView{}, err
}
return toSlotView(updated), nil
}
func (s *Service) slotEndTime(ctx context.Context, scheduleDayID, startTime string) (string, error) {
if _, err := s.Q.GetScheduleDay(ctx, scheduleDayID); err != nil {
return "", err
}
return addMinutes(startTime, defaultSlotLengthMinutes), nil
}
func toSlotView(slot db.TimeSlot) TimeSlotView {
return TimeSlotView{
ID: slot.ID,
StartTime: slot.StartTime,
Status: slot.Status,
}
}