1406 lines
51 KiB
Go
1406 lines
51 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/base32"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"mime"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.118im.com/im_team/im_workbench/internal/config"
|
|
"github.com/jmoiron/sqlx"
|
|
"golang.org/x/crypto/xtea"
|
|
)
|
|
|
|
type Store struct {
|
|
db *sqlx.DB
|
|
dsn string
|
|
tenantID int64
|
|
tenantStores map[int64]*Store
|
|
tenantStoreMu sync.Mutex
|
|
}
|
|
|
|
const defaultTenantID int64 = 1
|
|
|
|
var (
|
|
uidCipherMu sync.RWMutex
|
|
uidCipher *xtea.Cipher
|
|
)
|
|
|
|
func init() {
|
|
if err := SetUIDKey("dkXxWLwqUNRfYmdXRzOkZw=="); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
func SetUIDKey(encoded string) error {
|
|
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
|
|
if err != nil {
|
|
return fmt.Errorf("invalid uid key: %w", err)
|
|
}
|
|
cipher, err := xtea.NewCipher(key)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid uid key: %w", err)
|
|
}
|
|
uidCipherMu.Lock()
|
|
uidCipher = cipher
|
|
uidCipherMu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func Open(cfg config.MySQLConfig) (*Store, error) {
|
|
st, err := openDSN(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
st.tenantStores = make(map[int64]*Store)
|
|
return st, nil
|
|
}
|
|
|
|
func openDSN(cfg config.MySQLConfig) (*Store, error) {
|
|
db, err := sqlx.Open("mysql", cfg.DSN)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db.SetMaxOpenConns(cfg.MaxOpenConns)
|
|
db.SetMaxIdleConns(cfg.MaxIdleConns)
|
|
db.SetConnMaxLifetime(30 * time.Minute)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := db.PingContext(ctx); err != nil {
|
|
_ = db.Close()
|
|
return nil, err
|
|
}
|
|
return &Store{db: db, dsn: cfg.DSN}, nil
|
|
}
|
|
|
|
func (s *Store) tenant() int64 {
|
|
if s != nil && s.tenantID > 0 {
|
|
return s.tenantID
|
|
}
|
|
return defaultTenantID
|
|
}
|
|
|
|
func (s *Store) Close() error {
|
|
if s == nil || s.db == nil {
|
|
return nil
|
|
}
|
|
s.tenantStoreMu.Lock()
|
|
for id, st := range s.tenantStores {
|
|
if st != nil && st != s && st.db != s.db {
|
|
_ = st.Close()
|
|
}
|
|
delete(s.tenantStores, id)
|
|
}
|
|
s.tenantStoreMu.Unlock()
|
|
return s.db.Close()
|
|
}
|
|
|
|
// ForTenant returns a tenant-scoped Store over the configured shared database.
|
|
func (s *Store) ForTenant(ctx context.Context, tenantID int64, tenantCode string) (*Store, error) {
|
|
if s == nil {
|
|
return nil, errors.New("store is nil")
|
|
}
|
|
_ = ctx
|
|
_ = tenantCode
|
|
cacheID := tenantID
|
|
if cacheID <= 0 {
|
|
cacheID = defaultTenantID
|
|
}
|
|
if cacheID == s.tenant() {
|
|
return s, nil
|
|
}
|
|
s.tenantStoreMu.Lock()
|
|
if st := s.tenantStores[cacheID]; st != nil {
|
|
s.tenantStoreMu.Unlock()
|
|
return st, nil
|
|
}
|
|
st := &Store{db: s.db, dsn: s.dsn, tenantID: cacheID}
|
|
s.tenantStores[cacheID] = st
|
|
s.tenantStoreMu.Unlock()
|
|
return st, nil
|
|
}
|
|
|
|
func (s *Store) WorkbenchModules(ctx context.Context, userID int64) ([]WorkbenchModule, int, error) {
|
|
var modules []WorkbenchModule
|
|
err := s.db.SelectContext(ctx, &modules, `SELECT code,name,IFNULL(icon_file_id,'') AS icon_file_id,route,sort_order,0 AS unread_count,enabled,permission
|
|
FROM im_workbench_apps WHERE tenant_id=? AND enabled=1 ORDER BY sort_order ASC,id ASC`, s.tenant())
|
|
if err != nil {
|
|
if isMissingTable(err) {
|
|
return []WorkbenchModule{}, 1, nil
|
|
}
|
|
return nil, 0, err
|
|
}
|
|
for i := range modules {
|
|
modules[i].UnreadCount = 0
|
|
if modules[i].Permission == "" {
|
|
modules[i].Permission = "enabled"
|
|
}
|
|
}
|
|
return modules, 1, nil
|
|
}
|
|
|
|
func (s *Store) MeetingUnreadCount(ctx context.Context, userID int64) (int, error) {
|
|
var count int
|
|
query := fmt.Sprintf(`SELECT COUNT(*) FROM %s
|
|
WHERE tenant_id=? AND user_id=? AND read_at IS NULL AND event_type LIKE 'meeting.%%'`, tenantSystemNotificationsTable(s.tenant()))
|
|
err := s.db.GetContext(ctx, &count, query, s.tenant(), userID)
|
|
if isMissingTable(err) {
|
|
return 0, nil
|
|
}
|
|
return count, err
|
|
}
|
|
|
|
func tenantSystemNotificationsTable(tenantID int64) string {
|
|
return fmt.Sprintf("im_%03d_system_notifications", tenantID)
|
|
}
|
|
|
|
func (s *Store) Departments(ctx context.Context, userID int64, parentID, cursor string, limit int) ([]OrganizationDepartment, string, error) {
|
|
if parentID == "" {
|
|
parentID = "root"
|
|
}
|
|
var items []OrganizationDepartment
|
|
query := `SELECT d.department_id,d.name,d.parent_id,
|
|
(SELECT COUNT(*) FROM im_org_departments c WHERE c.tenant_id=d.tenant_id AND c.parent_id=d.department_id) AS child_count,
|
|
(SELECT COUNT(*) FROM im_org_members m WHERE m.tenant_id=d.tenant_id AND m.department_id=d.department_id AND m.employment_state='active') AS member_count,
|
|
d.version
|
|
FROM im_org_departments d WHERE d.tenant_id=? AND d.parent_id=?`
|
|
args := []any{s.tenant(), parentID}
|
|
if cursor != "" {
|
|
query += ` AND d.department_id>?`
|
|
args = append(args, cursor)
|
|
}
|
|
query += ` ORDER BY d.department_id ASC LIMIT ?`
|
|
args = append(args, limit+1)
|
|
if err := s.db.SelectContext(ctx, &items, query, args...); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return trimDepartmentPage(items, limit)
|
|
}
|
|
|
|
func (s *Store) DepartmentMembers(ctx context.Context, userID int64, departmentID, cursor string, limit int) ([]OrganizationMember, string, error) {
|
|
var items []OrganizationMember
|
|
query := `SELECT user_id,department_id,display_name,position,IFNULL(mobile,'') AS mobile,IFNULL(email,'') AS email,
|
|
IFNULL(avatar_file_id,'') AS avatar_file_id,employment_state AS status,contactable,version
|
|
FROM im_org_members WHERE tenant_id=? AND department_id=? AND employment_state='active'`
|
|
args := []any{s.tenant(), departmentID}
|
|
if cursor != "" {
|
|
query += ` AND user_id>?`
|
|
args = append(args, cursor)
|
|
}
|
|
query += ` ORDER BY user_id ASC LIMIT ?`
|
|
args = append(args, limit+1)
|
|
if err := s.db.SelectContext(ctx, &items, query, args...); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return trimMemberPage(items, limit)
|
|
}
|
|
|
|
func (s *Store) Members(ctx context.Context, userID int64, departmentID, keyword, cursor string, limit int) ([]OrganizationMember, string, error) {
|
|
var items []OrganizationMember
|
|
where := []string{"tenant_id=?", "employment_state='active'"}
|
|
args := []any{s.tenant()}
|
|
if departmentID != "" {
|
|
where = append(where, "department_id=?")
|
|
args = append(args, departmentID)
|
|
}
|
|
if keyword != "" {
|
|
where = append(where, "(display_name LIKE ? OR position LIKE ? OR mobile LIKE ?)")
|
|
like := "%" + keyword + "%"
|
|
args = append(args, like, like, like)
|
|
}
|
|
if cursor != "" {
|
|
where = append(where, "user_id>?")
|
|
args = append(args, cursor)
|
|
}
|
|
query := `SELECT user_id,department_id,display_name,position,IFNULL(mobile,'') AS mobile,IFNULL(email,'') AS email,
|
|
IFNULL(avatar_file_id,'') AS avatar_file_id,employment_state AS status,contactable,version
|
|
FROM im_org_members WHERE ` + strings.Join(where, " AND ") + ` ORDER BY user_id ASC LIMIT ?`
|
|
args = append(args, limit+1)
|
|
if err := s.db.SelectContext(ctx, &items, query, args...); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return trimMemberPage(items, limit)
|
|
}
|
|
|
|
func (s *Store) AttendanceConfig(ctx context.Context, userID int64, date time.Time) (*AttendanceConfig, error) {
|
|
var rule AttendanceRule
|
|
if err := s.db.GetContext(ctx, &rule, `SELECT rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed,NULL AS appeal_deadline
|
|
FROM im_attendance_rules WHERE tenant_id=? AND state='active' ORDER BY id ASC LIMIT 1`, s.tenant()); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return &AttendanceConfig{Date: date, Timezone: "Asia/Shanghai", Rule: AttendanceRule{}, Shifts: []AttendanceShift{}}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
var shifts []AttendanceShift
|
|
if err := s.db.SelectContext(ctx, &shifts, `SELECT shift_id,rule_id,name,work_start,work_end,checkin_start,checkout_end
|
|
FROM im_attendance_shifts WHERE tenant_id=? AND rule_id=? AND state='active' ORDER BY sort_order ASC,id ASC`, s.tenant(), rule.RuleID); err != nil {
|
|
return nil, err
|
|
}
|
|
record, _ := s.attendanceRecordByDate(ctx, userID, date)
|
|
return &AttendanceConfig{Date: date, Timezone: "Asia/Shanghai", Rule: rule, Shifts: shifts, TodayRecord: record}, nil
|
|
}
|
|
|
|
func (s *Store) AttendanceRecords(ctx context.Context, userID int64, opts AttendanceListOptions) ([]AttendanceRecord, string, *AttendanceStats, error) {
|
|
where := []string{"tenant_id=?"}
|
|
args := []any{s.tenant()}
|
|
if opts.UserID > 0 {
|
|
where = append(where, "user_id=?")
|
|
args = append(args, opts.UserID)
|
|
} else if opts.Scope != "managed" {
|
|
where = append(where, "user_id=?")
|
|
args = append(args, userID)
|
|
}
|
|
if opts.Date != "" {
|
|
where = append(where, "record_date=?")
|
|
args = append(args, opts.Date)
|
|
}
|
|
if opts.From != "" {
|
|
where = append(where, "record_date>=?")
|
|
args = append(args, opts.From)
|
|
}
|
|
if opts.To != "" {
|
|
where = append(where, "record_date<=?")
|
|
args = append(args, opts.To)
|
|
}
|
|
if opts.Status != "" {
|
|
where = append(where, "status=?")
|
|
args = append(args, opts.Status)
|
|
}
|
|
if opts.Cursor != "" {
|
|
where = append(where, "id<?")
|
|
args = append(args, opts.Cursor)
|
|
}
|
|
query := `SELECT record_id,user_id,record_date,rule_id,shift_id,status,exceptions,appeal_status,version,created_at,updated_at
|
|
FROM im_attendance_records WHERE ` + strings.Join(where, " AND ") + ` ORDER BY id DESC LIMIT ?`
|
|
args = append(args, opts.Limit+1)
|
|
var records []AttendanceRecord
|
|
if err := s.db.SelectContext(ctx, &records, query, args...); err != nil {
|
|
return nil, "", nil, err
|
|
}
|
|
next := ""
|
|
if len(records) > opts.Limit {
|
|
next = records[opts.Limit-1].RecordID
|
|
records = records[:opts.Limit]
|
|
}
|
|
for i := range records {
|
|
records[i].Checkins, _ = s.attendanceCheckins(ctx, records[i].RecordID)
|
|
}
|
|
stats := &AttendanceStats{ExpectedCount: len(records), CheckedCount: len(records)}
|
|
return records, next, stats, nil
|
|
}
|
|
|
|
type AttendanceListOptions struct {
|
|
Scope string
|
|
From string
|
|
To string
|
|
Date string
|
|
Status string
|
|
Cursor string
|
|
Limit int
|
|
UserID int64
|
|
}
|
|
|
|
func (s *Store) CreateAttendanceCheckin(ctx context.Context, userID int64, checkinID string, req AttendanceCheckinRequest) (*AttendanceRecord, *AttendanceCheckin, error) {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
recordID := fmt.Sprintf("att_%d_%s_%s", userID, req.Date.Format("20060102"), req.ShiftID)
|
|
result := "normal"
|
|
status := "checked_in"
|
|
if req.Action == "check_out" {
|
|
status = "normal"
|
|
}
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_records(tenant_id,record_id,user_id,record_date,rule_id,shift_id,status,exceptions,appeal_status,version,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,'[]','none',1,?,?)
|
|
ON DUPLICATE KEY UPDATE status=VALUES(status),version=version+1,updated_at=VALUES(updated_at)`,
|
|
s.tenant(), recordID, userID, req.Date, req.RuleID, req.ShiftID, status, now, now)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_checkins(tenant_id,checkin_id,record_id,user_id,record_date,action,rule_id,shift_id,client_time,server_time,result,location,wifi,device,idempotency_key)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
s.tenant(), checkinID, recordID, userID, req.Date, req.Action, req.RuleID, req.ShiftID, req.ClientTime, now, result, req.Location, req.Wifi, req.Device, req.IdempotencyKey)
|
|
if isDuplicate(err) {
|
|
_ = tx.Rollback()
|
|
return s.attendanceRecordAndCheckinByIdempotency(ctx, userID, req.IdempotencyKey)
|
|
}
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return s.attendanceRecordAndCheckinByIdempotency(ctx, userID, req.IdempotencyKey)
|
|
}
|
|
|
|
type AttendanceCheckinRequest struct {
|
|
Date time.Time
|
|
Action string
|
|
RuleID string
|
|
ShiftID string
|
|
ClientTime time.Time
|
|
Location string
|
|
Wifi string
|
|
Device string
|
|
IdempotencyKey string
|
|
}
|
|
|
|
func (s *Store) AttendanceAppeals(ctx context.Context, userID int64, status, cursor string, limit int) ([]AttendanceAppeal, string, error) {
|
|
where := []string{"tenant_id=?", "user_id=?"}
|
|
args := []any{s.tenant(), userID}
|
|
if status != "" {
|
|
where = append(where, "status=?")
|
|
args = append(args, status)
|
|
}
|
|
if cursor != "" {
|
|
where = append(where, "id<?")
|
|
args = append(args, cursor)
|
|
}
|
|
query := `SELECT appeal_id,record_id,user_id,appeal_type,reason,target_action,target_time,attachment_file_ids,status,version,submitted_at,created_at,updated_at
|
|
FROM im_attendance_appeals WHERE ` + strings.Join(where, " AND ") + ` ORDER BY id DESC LIMIT ?`
|
|
args = append(args, limit+1)
|
|
var items []AttendanceAppeal
|
|
if err := s.db.SelectContext(ctx, &items, query, args...); err != nil {
|
|
return nil, "", err
|
|
}
|
|
next := ""
|
|
if len(items) > limit {
|
|
next = items[limit-1].AppealID
|
|
items = items[:limit]
|
|
}
|
|
return items, next, nil
|
|
}
|
|
|
|
func (s *Store) CreateAttendanceAppeal(ctx context.Context, userID int64, appeal AttendanceAppeal, idempotencyKey string) (*AttendanceAppeal, error) {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
if appeal.Status == "" {
|
|
appeal.Status = "pending"
|
|
}
|
|
_, err := s.db.ExecContext(ctx, `INSERT INTO im_attendance_appeals(tenant_id,appeal_id,record_id,user_id,appeal_type,reason,target_action,target_time,attachment_file_ids,status,version,idempotency_key,submitted_at,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,1,?,?,?,?)`,
|
|
s.tenant(), appeal.AppealID, appeal.RecordID, userID, appeal.AppealType, appeal.Reason, appeal.TargetAction, appeal.TargetTime, appeal.AttachmentFileIDs, appeal.Status, idempotencyKey, now, now, now)
|
|
if isDuplicate(err) {
|
|
var existing AttendanceAppeal
|
|
if getErr := s.db.GetContext(ctx, &existing, `SELECT appeal_id,record_id,user_id,appeal_type,reason,target_action,target_time,attachment_file_ids,status,version,submitted_at,created_at,updated_at
|
|
FROM im_attendance_appeals WHERE tenant_id=? AND user_id=? AND idempotency_key=? LIMIT 1`, s.tenant(), userID, idempotencyKey); getErr != nil {
|
|
return nil, getErr
|
|
}
|
|
return &existing, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &appeal, nil
|
|
}
|
|
|
|
func (s *Store) Meetings(ctx context.Context, userID int64, cursor string, limit int) ([]Meeting, string, error) {
|
|
var items []Meeting
|
|
query := `SELECT meeting_id,organizer_id,recorder_user_id,title,start_at,end_at,location,state,reminder_minutes,idempotency_key,version,canceled_at,created_at,updated_at
|
|
FROM im_meetings WHERE tenant_id=? AND (organizer_id=? OR EXISTS(SELECT 1 FROM im_meeting_participants p WHERE p.tenant_id=im_meetings.tenant_id AND p.meeting_id=im_meetings.meeting_id AND p.user_id=?))`
|
|
args := []any{s.tenant(), userID, userID}
|
|
if cursor != "" {
|
|
query += ` AND meeting_id>?`
|
|
args = append(args, cursor)
|
|
}
|
|
query += ` ORDER BY start_at DESC,id DESC LIMIT ?`
|
|
args = append(args, limit+1)
|
|
if err := s.db.SelectContext(ctx, &items, query, args...); err != nil {
|
|
return nil, "", err
|
|
}
|
|
next := ""
|
|
if len(items) > limit {
|
|
next = items[limit-1].MeetingID
|
|
items = items[:limit]
|
|
}
|
|
for i := range items {
|
|
items[i].Participants, _ = s.meetingParticipants(ctx, items[i].MeetingID)
|
|
_ = s.hydrateMeetingUsers(ctx, &items[i])
|
|
}
|
|
return items, next, nil
|
|
}
|
|
|
|
func (s *Store) Meeting(ctx context.Context, userID int64, meetingID string) (*Meeting, error) {
|
|
var meeting Meeting
|
|
if err := s.db.GetContext(ctx, &meeting, `SELECT meeting_id,organizer_id,recorder_user_id,title,start_at,end_at,location,state,reminder_minutes,idempotency_key,version,canceled_at,created_at,updated_at
|
|
FROM im_meetings WHERE tenant_id=? AND meeting_id=? LIMIT 1`, s.tenant(), meetingID); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if isMissingTable(err) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
meeting.Participants, _ = s.meetingParticipants(ctx, meeting.MeetingID)
|
|
_ = s.hydrateMeetingUsers(ctx, &meeting)
|
|
if !meetingVisible(meeting, userID) {
|
|
return nil, ErrForbidden
|
|
}
|
|
return &meeting, nil
|
|
}
|
|
|
|
func (s *Store) CreateMeeting(ctx context.Context, userID int64, meeting Meeting, attendees []int64) (*Meeting, error) {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
if meeting.RecorderID == 0 {
|
|
meeting.RecorderID = userID
|
|
}
|
|
if meeting.State == "" {
|
|
meeting.State = "scheduled"
|
|
}
|
|
reminders := meeting.ReminderMinutes
|
|
if reminders == "" {
|
|
reminders = "[]"
|
|
}
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO im_meetings(tenant_id,meeting_id,organizer_id,recorder_user_id,title,start_at,end_at,location,state,reminder_minutes,idempotency_key,version,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,1,?,?)`, s.tenant(), meeting.MeetingID, userID, meeting.RecorderID, meeting.Title, meeting.StartAt, meeting.EndAt, meeting.Location, meeting.State, reminders, meeting.IdempotencyKey, now, now)
|
|
if isDuplicate(err) && meeting.IdempotencyKey != "" {
|
|
_ = tx.Rollback()
|
|
var existingID string
|
|
if getErr := s.db.GetContext(ctx, &existingID, `SELECT meeting_id FROM im_meetings WHERE tenant_id=? AND organizer_id=? AND idempotency_key=? LIMIT 1`, s.tenant(), userID, meeting.IdempotencyKey); getErr != nil {
|
|
return nil, getErr
|
|
}
|
|
return s.Meeting(ctx, userID, existingID)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seen := map[int64]bool{userID: true}
|
|
attendees = append(attendees, userID, meeting.RecorderID)
|
|
for _, uid := range attendees {
|
|
if uid <= 0 || seen[uid] {
|
|
continue
|
|
}
|
|
seen[uid] = true
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO im_meeting_participants(tenant_id,meeting_id,user_id,response) VALUES(?,?,?,?)`, s.tenant(), meeting.MeetingID, uid, "pending")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.Meeting(ctx, userID, meeting.MeetingID)
|
|
}
|
|
|
|
func (s *Store) UpdateMeeting(ctx context.Context, userID int64, meetingID string, patch MeetingPatch) (*Meeting, error) {
|
|
meeting, err := s.Meeting(ctx, userID, meetingID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if meeting.OrganizerID != userID || meeting.State != "scheduled" || patch.Version != meeting.Version {
|
|
return nil, ErrConflict
|
|
}
|
|
sets := []string{"version=version+1", "updated_at=?"}
|
|
args := []any{time.Now().UTC().Round(time.Millisecond)}
|
|
if patch.Title != nil {
|
|
sets = append(sets, "title=?")
|
|
args = append(args, strings.TrimSpace(*patch.Title))
|
|
}
|
|
if patch.RecorderID != nil {
|
|
sets = append(sets, "recorder_user_id=?")
|
|
args = append(args, *patch.RecorderID)
|
|
}
|
|
args = append(args, s.tenant(), meetingID)
|
|
if _, err := s.db.ExecContext(ctx, `UPDATE im_meetings SET `+strings.Join(sets, ",")+` WHERE tenant_id=? AND meeting_id=?`, args...); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.Meeting(ctx, userID, meetingID)
|
|
}
|
|
|
|
type MeetingPatch struct {
|
|
Title *string
|
|
RecorderID *int64
|
|
Version int
|
|
}
|
|
|
|
func (s *Store) CancelMeeting(ctx context.Context, userID int64, meetingID string) (*Meeting, error) {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
if _, err := s.db.ExecContext(ctx, `UPDATE im_meetings SET state='canceled',canceled_at=?,version=version+1,updated_at=? WHERE tenant_id=? AND meeting_id=? AND organizer_id=? AND state='scheduled'`, now, now, s.tenant(), meetingID, userID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.Meeting(ctx, userID, meetingID)
|
|
}
|
|
|
|
func (s *Store) RespondMeeting(ctx context.Context, userID int64, meetingID, response string, version int) (*Meeting, error) {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
if _, err := s.db.ExecContext(ctx, `UPDATE im_meeting_participants SET response=?,response_at=? WHERE tenant_id=? AND meeting_id=? AND user_id=?`, response, now, s.tenant(), meetingID, userID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.Meeting(ctx, userID, meetingID)
|
|
}
|
|
|
|
func (s *Store) MeetingNotes(ctx context.Context, userID int64, queryText, cursor string, limit int) ([]Meeting, string, error) {
|
|
items, next, err := s.Meetings(ctx, userID, cursor, limit)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
filtered := items[:0]
|
|
now := time.Now()
|
|
for _, item := range items {
|
|
if item.EndAt.Before(now) && item.State != "canceled" && (queryText == "" || strings.Contains(item.Title, queryText)) {
|
|
filtered = append(filtered, item)
|
|
}
|
|
}
|
|
return filtered, next, nil
|
|
}
|
|
|
|
func (s *Store) MeetingNoteFiles(ctx context.Context, userID int64, meetingID string) ([]MeetingNoteFile, error) {
|
|
if _, err := s.Meeting(ctx, userID, meetingID); err != nil {
|
|
return nil, err
|
|
}
|
|
var files []MeetingNoteFile
|
|
if err := s.db.SelectContext(ctx, &files, `SELECT note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,preview_file_id,version,created_at,updated_at
|
|
FROM im_meeting_note_files WHERE tenant_id=? AND meeting_id=? AND status='active' ORDER BY id DESC`, s.tenant(), meetingID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.hydrateMeetingNoteFilesUploaders(ctx, files); err != nil {
|
|
return nil, err
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
func (s *Store) CreateMeetingNoteFile(ctx context.Context, userID int64, meetingID, noteFileID string, input MeetingNoteFileInput, idempotencyKey string) (*MeetingNoteFile, error) {
|
|
meeting, err := s.Meeting(ctx, userID, meetingID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if meeting.RecorderID != userID && meeting.OrganizerID != userID {
|
|
return nil, ErrForbidden
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
fileID := strings.TrimSpace(input.FileID)
|
|
meta := s.fileUploadMetadata(ctx, fileID)
|
|
fileName, mimeType, fileSize, fileETag := normalizeMeetingNoteFileMetadata(input, meta, fileID)
|
|
_, err = s.db.ExecContext(ctx, `INSERT INTO im_meeting_note_files(tenant_id,note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,preview_file_id,version,idempotency_key,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,'active','processing','native','',1,?,?,?)`, s.tenant(), noteFileID, meetingID, fileID, userID, fileName, mimeType, fileSize, fileETag, idempotencyKey, now, now)
|
|
if isDuplicate(err) && idempotencyKey != "" {
|
|
var existing MeetingNoteFile
|
|
if getErr := s.db.GetContext(ctx, &existing, `SELECT note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,preview_file_id,version,created_at,updated_at
|
|
FROM im_meeting_note_files WHERE tenant_id=? AND meeting_id=? AND uploader_id=? AND idempotency_key=? LIMIT 1`, s.tenant(), meetingID, userID, idempotencyKey); getErr != nil {
|
|
return nil, getErr
|
|
}
|
|
if getErr := s.hydrateMeetingNoteFileUploader(ctx, &existing); getErr != nil {
|
|
return nil, getErr
|
|
}
|
|
return &existing, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var created MeetingNoteFile
|
|
if err := s.db.GetContext(ctx, &created, `SELECT note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,preview_file_id,version,created_at,updated_at
|
|
FROM im_meeting_note_files WHERE tenant_id=? AND note_file_id=? LIMIT 1`, s.tenant(), noteFileID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.hydrateMeetingNoteFileUploader(ctx, &created); err != nil {
|
|
return nil, err
|
|
}
|
|
return &created, nil
|
|
}
|
|
|
|
func (s *Store) ReplaceMeetingNoteFile(ctx context.Context, userID int64, meetingID, noteFileID string, input MeetingNoteFileInput, idempotencyKey string, version int) (*MeetingNoteFile, error) {
|
|
meeting, err := s.Meeting(ctx, userID, meetingID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if meeting.RecorderID != userID && meeting.OrganizerID != userID {
|
|
return nil, ErrForbidden
|
|
}
|
|
fileID := strings.TrimSpace(input.FileID)
|
|
meta := s.fileUploadMetadata(ctx, fileID)
|
|
fileName, mimeType, fileSize, fileETag := normalizeMeetingNoteFileMetadata(input, meta, fileID)
|
|
res, err := s.db.ExecContext(ctx, `UPDATE im_meeting_note_files SET file_id=?,file_name=?,mime_type=?,file_size=?,file_etag=?,preview_status='processing',preview_file_id='',version=version+1,idempotency_key=?,updated_at=?
|
|
WHERE tenant_id=? AND meeting_id=? AND note_file_id=? AND status='active' AND version=?`,
|
|
fileID, fileName, mimeType, fileSize, fileETag, idempotencyKey, time.Now().UTC().Round(time.Millisecond), s.tenant(), meetingID, noteFileID, version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
|
return nil, ErrConflict
|
|
}
|
|
return s.meetingNoteFile(ctx, meetingID, noteFileID)
|
|
}
|
|
|
|
type fileUploadMetadata struct {
|
|
MIMEType string `db:"mimetype"`
|
|
FileSize int64 `db:"size"`
|
|
FileETag string `db:"etag"`
|
|
Location string `db:"location"`
|
|
OriginalName string `db:"original_name"`
|
|
}
|
|
|
|
func (s *Store) fileUploadMetadata(ctx context.Context, fileID string) fileUploadMetadata {
|
|
var meta fileUploadMetadata
|
|
decodedID, storageName := parseUploadFileID(fileID)
|
|
locationCandidates := uploadLocationCandidates(fileID, storageName)
|
|
if decodedID <= 0 && len(locationCandidates) == 0 {
|
|
return meta
|
|
}
|
|
if decodedID > 0 {
|
|
err := s.db.GetContext(ctx, &meta, `SELECT IFNULL(mimetype,'') AS mimetype,IFNULL(size,0) AS size,IFNULL(etag,'') AS etag,IFNULL(location,'') AS location,IFNULL(original_name,'') AS original_name FROM im_fileuploads WHERE tenant_id=? AND id=? LIMIT 1`, s.tenant(), decodedID)
|
|
if err == nil {
|
|
return meta
|
|
}
|
|
}
|
|
for _, candidate := range locationCandidates {
|
|
err := s.db.GetContext(ctx, &meta, `SELECT IFNULL(mimetype,'') AS mimetype,IFNULL(size,0) AS size,IFNULL(etag,'') AS etag,IFNULL(location,'') AS location,IFNULL(original_name,'') AS original_name
|
|
FROM im_fileuploads WHERE tenant_id=? AND (location=? OR location LIKE ?) ORDER BY createdat DESC LIMIT 1`, s.tenant(), candidate, "%/"+candidate)
|
|
if err == nil {
|
|
return meta
|
|
}
|
|
}
|
|
return meta
|
|
}
|
|
|
|
func parseUploadFileID(fileID string) (int64, string) {
|
|
name := uploadFileName(fileID)
|
|
if dot := strings.LastIndexByte(name, '.'); dot > 0 {
|
|
ext := name[dot+1:]
|
|
if len(ext) == 0 || len(ext) > 16 {
|
|
return 0, ""
|
|
}
|
|
for _, r := range ext {
|
|
if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') && (r < '0' || r > '9') {
|
|
return 0, ""
|
|
}
|
|
}
|
|
name = name[:dot]
|
|
}
|
|
decoded, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(name)
|
|
if err != nil || len(decoded) < 8 {
|
|
if isStorageFileName(name) {
|
|
return 0, name
|
|
}
|
|
return 0, ""
|
|
}
|
|
storageData := make([]byte, 8)
|
|
binary.LittleEndian.PutUint64(storageData, binary.LittleEndian.Uint64(decoded[:8]))
|
|
storageName := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(storageData))
|
|
|
|
plain := make([]byte, 8)
|
|
uidCipherMu.RLock()
|
|
cipher := uidCipher
|
|
uidCipherMu.RUnlock()
|
|
cipher.Decrypt(plain, decoded[:8])
|
|
return int64(binary.LittleEndian.Uint64(plain)), storageName
|
|
}
|
|
|
|
func uploadFileName(fileID string) string {
|
|
name := strings.TrimSpace(fileID)
|
|
if parsed, err := url.Parse(name); err == nil && parsed.Path != "" {
|
|
name = parsed.Path
|
|
}
|
|
if q := strings.IndexAny(name, "?#"); q >= 0 {
|
|
name = name[:q]
|
|
}
|
|
name = strings.TrimRight(strings.ReplaceAll(name, "\\", "/"), "/")
|
|
if slash := strings.LastIndexByte(name, '/'); slash >= 0 {
|
|
name = name[slash+1:]
|
|
}
|
|
return strings.TrimSpace(name)
|
|
}
|
|
|
|
func uploadLocationCandidates(fileID, storageName string) []string {
|
|
seen := map[string]bool{}
|
|
var candidates []string
|
|
add := func(value string) {
|
|
value = strings.Trim(strings.TrimSpace(value), "/")
|
|
if value == "" || seen[value] {
|
|
return
|
|
}
|
|
seen[value] = true
|
|
candidates = append(candidates, value)
|
|
}
|
|
add(storageName)
|
|
raw := strings.TrimSpace(fileID)
|
|
if parsed, err := url.Parse(raw); err == nil && parsed.Path != "" {
|
|
add(parsed.Path)
|
|
}
|
|
add(raw)
|
|
return candidates
|
|
}
|
|
|
|
func isStorageFileName(name string) bool {
|
|
if len(name) != 13 {
|
|
return false
|
|
}
|
|
for _, r := range name {
|
|
if (r < 'a' || r > 'z') && (r < '2' || r > '7') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func normalizeMeetingNoteFileMetadata(input MeetingNoteFileInput, meta fileUploadMetadata, fileID string) (string, string, int64, string) {
|
|
mimeType := firstNonEmpty(input.MIMEType, meta.MIMEType)
|
|
fileSize := int64(0)
|
|
if input.FileSize != nil && *input.FileSize > 0 {
|
|
fileSize = *input.FileSize
|
|
} else if meta.FileSize > 0 {
|
|
fileSize = meta.FileSize
|
|
}
|
|
fileETag := firstNonEmpty(input.FileETag, meta.FileETag)
|
|
fileName := normalizeMeetingNoteFileName(firstNonEmpty(input.FileName, meta.OriginalName, fileNameFromLocation(meta.Location), fallbackFileName(fileID, mimeType)), fileID)
|
|
return fileName, strings.TrimSpace(mimeType), fileSize, strings.TrimSpace(fileETag)
|
|
}
|
|
|
|
func normalizeMeetingNoteFileName(fileName, fileID string) string {
|
|
fileName = strings.TrimSpace(strings.ReplaceAll(fileName, "\\", "/"))
|
|
fileName = filepath.Base(fileName)
|
|
if fileName == "." || fileName == "/" || fileName == "" {
|
|
fileName = strings.TrimSpace(fileID)
|
|
}
|
|
if len([]rune(fileName)) <= 255 {
|
|
return fileName
|
|
}
|
|
runes := []rune(fileName)
|
|
ext := filepath.Ext(fileName)
|
|
if ext == "" || len([]rune(ext)) >= 255 {
|
|
return string(runes[:255])
|
|
}
|
|
extRunes := []rune(ext)
|
|
return string(runes[:255-len(extRunes)]) + ext
|
|
}
|
|
|
|
func fileNameFromLocation(location string) string {
|
|
name := strings.TrimSpace(strings.ReplaceAll(location, "\\", "/"))
|
|
name = filepath.Base(name)
|
|
if name == "." || name == "/" {
|
|
return ""
|
|
}
|
|
if filepath.Ext(name) == "" {
|
|
return ""
|
|
}
|
|
return name
|
|
}
|
|
|
|
func fallbackFileName(fileID, mimeType string) string {
|
|
fileID = strings.TrimSpace(fileID)
|
|
if filepath.Ext(fileID) != "" || strings.TrimSpace(mimeType) == "" {
|
|
return fileID
|
|
}
|
|
exts, _ := mime.ExtensionsByType(strings.TrimSpace(mimeType))
|
|
if len(exts) == 0 {
|
|
return fileID
|
|
}
|
|
return fileID + strings.ToLower(exts[0])
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Store) DeleteMeetingNoteFile(ctx context.Context, userID int64, meetingID, noteFileID, idempotencyKey string, version int) (*MeetingNoteFile, error) {
|
|
meeting, err := s.Meeting(ctx, userID, meetingID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if meeting.RecorderID != userID && meeting.OrganizerID != userID {
|
|
return nil, ErrForbidden
|
|
}
|
|
res, err := s.db.ExecContext(ctx, `UPDATE im_meeting_note_files SET status='deleted',version=version+1,idempotency_key=?,updated_at=?
|
|
WHERE tenant_id=? AND meeting_id=? AND note_file_id=? AND status='active' AND version=?`,
|
|
idempotencyKey, time.Now().UTC().Round(time.Millisecond), s.tenant(), meetingID, noteFileID, version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
|
return nil, ErrConflict
|
|
}
|
|
return s.meetingNoteFile(ctx, meetingID, noteFileID)
|
|
}
|
|
|
|
func (s *Store) DailyReports(ctx context.Context, userID int64, reportDate, cursor string, limit int) ([]DailyReport, string, error) {
|
|
var items []DailyReport
|
|
query := `SELECT report_id,author_id,report_date,content,attachments,status,read_state,version,submitted_at,created_at,updated_at FROM im_daily_reports WHERE tenant_id=? AND author_id=?`
|
|
args := []any{s.tenant(), userID}
|
|
if reportDate != "" {
|
|
query += ` AND report_date=?`
|
|
args = append(args, reportDate)
|
|
}
|
|
if cursor != "" {
|
|
query += ` AND report_id>?`
|
|
args = append(args, cursor)
|
|
}
|
|
query += ` ORDER BY report_date DESC,id DESC LIMIT ?`
|
|
args = append(args, limit+1)
|
|
if err := s.db.SelectContext(ctx, &items, query, args...); err != nil {
|
|
return nil, "", err
|
|
}
|
|
next := ""
|
|
if len(items) > limit {
|
|
next = items[limit-1].ReportID
|
|
items = items[:limit]
|
|
}
|
|
return items, next, nil
|
|
}
|
|
|
|
func (s *Store) CreateDailyDraft(ctx context.Context, userID int64, reportID string, reportDate time.Time) (*DailyReport, error) {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
_, err := s.db.ExecContext(ctx, `INSERT INTO im_daily_reports(tenant_id,report_id,author_id,report_date,content,attachments,status,read_state,version,created_at,updated_at)
|
|
VALUES(?,?,?,?,'','[]','draft','unread',1,?,?)`, s.tenant(), reportID, userID, reportDate, now, now)
|
|
if isDuplicate(err) {
|
|
return nil, ErrConflict
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.DailyReport(ctx, userID, reportID)
|
|
}
|
|
|
|
func (s *Store) DailyReport(ctx context.Context, userID int64, reportID string) (*DailyReport, error) {
|
|
var report DailyReport
|
|
if err := s.db.GetContext(ctx, &report, `SELECT report_id,author_id,report_date,content,attachments,status,read_state,version,submitted_at,created_at,updated_at
|
|
FROM im_daily_reports WHERE tenant_id=? AND report_id=? LIMIT 1`, s.tenant(), reportID); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if report.AuthorID != userID {
|
|
return nil, ErrForbidden
|
|
}
|
|
return &report, nil
|
|
}
|
|
|
|
func (s *Store) SaveDailyDraft(ctx context.Context, userID int64, reportID, content string, attachments []string, version int) (*DailyReport, error) {
|
|
data, _ := json.Marshal(attachments)
|
|
res, err := s.db.ExecContext(ctx, `UPDATE im_daily_reports SET content=?,attachments=?,version=version+1,updated_at=? WHERE tenant_id=? AND report_id=? AND author_id=? AND version=?`,
|
|
content, string(data), time.Now().UTC().Round(time.Millisecond), s.tenant(), reportID, userID, version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
|
return nil, ErrConflict
|
|
}
|
|
return s.DailyReport(ctx, userID, reportID)
|
|
}
|
|
|
|
func (s *Store) SubmitDaily(ctx context.Context, userID int64, reportID string, version int) (*DailyReport, error) {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
res, err := s.db.ExecContext(ctx, `UPDATE im_daily_reports SET status='submitted',submitted_at=?,version=version+1,updated_at=? WHERE tenant_id=? AND report_id=? AND author_id=? AND version=?`, now, now, s.tenant(), reportID, userID, version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
|
return nil, ErrConflict
|
|
}
|
|
return s.DailyReport(ctx, userID, reportID)
|
|
}
|
|
|
|
func (s *Store) DailyReportStats(ctx context.Context, date time.Time, departmentID string) (*ReportStats, error) {
|
|
stats := &ReportStats{DepartmentID: strings.TrimSpace(departmentID)}
|
|
memberWhere, memberArgs := reportMemberScope(stats.DepartmentID)
|
|
memberWhere = append([]string{"tenant_id=?"}, memberWhere...)
|
|
memberArgs = append([]any{s.tenant()}, memberArgs...)
|
|
if err := s.db.GetContext(ctx, &stats.ExpectedCount,
|
|
`SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE `+strings.Join(memberWhere, " AND "), memberArgs...); err != nil {
|
|
if isMissingTable(err) {
|
|
return stats, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
args := append([]any{s.tenant(), date.Format("2006-01-02")}, memberArgs...)
|
|
if err := s.db.GetContext(ctx, &stats.SubmittedCount,
|
|
`SELECT COUNT(DISTINCT r.author_id)
|
|
FROM im_daily_reports AS r
|
|
INNER JOIN im_org_members AS m ON m.tenant_id=r.tenant_id AND m.user_id=r.author_id
|
|
WHERE r.tenant_id=? AND r.report_date=? AND r.status='submitted' AND `+strings.Join(prefixReportMemberScope(memberWhere), " AND "), args...); err != nil {
|
|
if isMissingTable(err) {
|
|
return stats, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount
|
|
if stats.MissingCount < 0 {
|
|
stats.MissingCount = 0
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
func (s *Store) MonthlyReports(ctx context.Context, userID int64, fromMonth, toMonth, cursor string, limit int) ([]MonthlyReport, string, error) {
|
|
var items []MonthlyReport
|
|
query := `SELECT report_id,author_id,report_month,work_summary,next_plan,attachments,status,read_state,version,submitted_at,created_at,updated_at FROM im_monthly_reports WHERE tenant_id=? AND author_id=?`
|
|
args := []any{s.tenant(), userID}
|
|
if fromMonth != "" {
|
|
query += ` AND report_month>=?`
|
|
args = append(args, fromMonth)
|
|
}
|
|
if toMonth != "" {
|
|
query += ` AND report_month<=?`
|
|
args = append(args, toMonth)
|
|
}
|
|
if cursor != "" {
|
|
query += ` AND report_id>?`
|
|
args = append(args, cursor)
|
|
}
|
|
query += ` ORDER BY report_month DESC,id DESC LIMIT ?`
|
|
args = append(args, limit+1)
|
|
if err := s.db.SelectContext(ctx, &items, query, args...); err != nil {
|
|
return nil, "", err
|
|
}
|
|
next := ""
|
|
if len(items) > limit {
|
|
next = items[limit-1].ReportID
|
|
items = items[:limit]
|
|
}
|
|
return items, next, nil
|
|
}
|
|
|
|
func (s *Store) CreateMonthlyDraft(ctx context.Context, userID int64, reportID, month string) (*MonthlyReport, error) {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
_, err := s.db.ExecContext(ctx, `INSERT INTO im_monthly_reports(tenant_id,report_id,author_id,report_month,work_summary,next_plan,attachments,status,read_state,version,created_at,updated_at)
|
|
VALUES(?,?,?,?,'','','[]','draft','unread',1,?,?)`, s.tenant(), reportID, userID, month, now, now)
|
|
if isDuplicate(err) {
|
|
return nil, ErrConflict
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.MonthlyReport(ctx, userID, reportID)
|
|
}
|
|
|
|
func (s *Store) MonthlyReport(ctx context.Context, userID int64, reportID string) (*MonthlyReport, error) {
|
|
var report MonthlyReport
|
|
if err := s.db.GetContext(ctx, &report, `SELECT report_id,author_id,report_month,work_summary,next_plan,attachments,status,read_state,version,submitted_at,created_at,updated_at
|
|
FROM im_monthly_reports WHERE tenant_id=? AND report_id=? LIMIT 1`, s.tenant(), reportID); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if report.AuthorID != userID {
|
|
return nil, ErrForbidden
|
|
}
|
|
return &report, nil
|
|
}
|
|
|
|
func (s *Store) SaveMonthlyDraft(ctx context.Context, userID int64, reportID, summary, nextPlan string, attachments []string, version int) (*MonthlyReport, error) {
|
|
data, _ := json.Marshal(attachments)
|
|
res, err := s.db.ExecContext(ctx, `UPDATE im_monthly_reports SET work_summary=?,next_plan=?,attachments=?,version=version+1,updated_at=? WHERE tenant_id=? AND report_id=? AND author_id=? AND version=?`,
|
|
summary, nextPlan, string(data), time.Now().UTC().Round(time.Millisecond), s.tenant(), reportID, userID, version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
|
return nil, ErrConflict
|
|
}
|
|
return s.MonthlyReport(ctx, userID, reportID)
|
|
}
|
|
|
|
func (s *Store) SubmitMonthly(ctx context.Context, userID int64, reportID string, version int) (*MonthlyReport, error) {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
res, err := s.db.ExecContext(ctx, `UPDATE im_monthly_reports SET status='submitted',submitted_at=?,version=version+1,updated_at=? WHERE tenant_id=? AND report_id=? AND author_id=? AND version=?`, now, now, s.tenant(), reportID, userID, version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
|
return nil, ErrConflict
|
|
}
|
|
return s.MonthlyReport(ctx, userID, reportID)
|
|
}
|
|
|
|
func (s *Store) ReopenMonthly(ctx context.Context, userID int64, reportID string, version int) (*MonthlyReport, error) {
|
|
res, err := s.db.ExecContext(ctx, `UPDATE im_monthly_reports SET status='draft',version=version+1,updated_at=? WHERE tenant_id=? AND report_id=? AND author_id=? AND version=?`,
|
|
time.Now().UTC().Round(time.Millisecond), s.tenant(), reportID, userID, version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
|
return nil, ErrConflict
|
|
}
|
|
return s.MonthlyReport(ctx, userID, reportID)
|
|
}
|
|
|
|
func (s *Store) MonthlyReportStats(ctx context.Context, month, departmentID string) (*ReportStats, error) {
|
|
stats := &ReportStats{DepartmentID: strings.TrimSpace(departmentID)}
|
|
memberWhere, memberArgs := reportMemberScope(stats.DepartmentID)
|
|
memberWhere = append([]string{"tenant_id=?"}, memberWhere...)
|
|
memberArgs = append([]any{s.tenant()}, memberArgs...)
|
|
if err := s.db.GetContext(ctx, &stats.ExpectedCount,
|
|
`SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE `+strings.Join(memberWhere, " AND "), memberArgs...); err != nil {
|
|
if isMissingTable(err) {
|
|
return stats, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
args := append([]any{s.tenant(), strings.TrimSpace(month)}, memberArgs...)
|
|
if err := s.db.GetContext(ctx, &stats.SubmittedCount,
|
|
`SELECT COUNT(DISTINCT r.author_id)
|
|
FROM im_monthly_reports AS r
|
|
INNER JOIN im_org_members AS m ON m.tenant_id=r.tenant_id AND m.user_id=r.author_id
|
|
WHERE r.tenant_id=? AND r.report_month=? AND r.status='submitted' AND `+strings.Join(prefixReportMemberScope(memberWhere), " AND "), args...); err != nil {
|
|
if isMissingTable(err) {
|
|
return stats, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount
|
|
if stats.MissingCount < 0 {
|
|
stats.MissingCount = 0
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
func (s *Store) MailboxConfig(ctx context.Context) (*MailboxConfig, error) {
|
|
var cfg MailboxConfig
|
|
if err := s.db.GetContext(ctx, &cfg, `SELECT enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,CAST(COALESCE(UNIX_TIMESTAMP(notice_updated_at),1) AS UNSIGNED) AS version FROM im_mailbox_config WHERE tenant_id=? LIMIT 1`, s.tenant()); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return &MailboxConfig{Enabled: true, AllowAnonymous: true, RequiredFields: `["subject","content"]`, MaxLength: 5000, AttachmentPolicy: `{"max_files":5}`, NoticeTitle: "总经理信箱", NoticeContent: "请如实填写诉求。", Version: 1}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
|
|
func (s *Store) CreateMailboxSubmission(ctx context.Context, userID int64, submission MailboxSubmission, idempotencyKey string) (*MailboxSubmission, error) {
|
|
cfg, err := s.MailboxConfig(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
submission.Subject = strings.TrimSpace(submission.Subject)
|
|
submission.Content = strings.TrimSpace(submission.Content)
|
|
if !cfg.Enabled || (submission.Anonymous && !cfg.AllowAnonymous) {
|
|
return nil, ErrPolicy
|
|
}
|
|
maxLength := cfg.MaxLength
|
|
if maxLength <= 0 {
|
|
maxLength = 5000
|
|
}
|
|
if len([]rune(submission.Subject)) > 200 || len([]rune(submission.Content)) > maxLength {
|
|
return nil, ErrPolicy
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
_, err = s.db.ExecContext(ctx, `INSERT INTO im_mailbox_submissions(tenant_id,submission_id,sender_id,subject,content,anonymous,attachment_file_ids,notice_version,idempotency_key,status,submitted_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, s.tenant(), submission.SubmissionID, userID, submission.Subject, submission.Content, submission.Anonymous, submission.AttachmentFileIDs, submission.NoticeVersion, idempotencyKey, "received", now)
|
|
if isDuplicate(err) && idempotencyKey != "" {
|
|
var existing MailboxSubmission
|
|
if getErr := s.db.GetContext(ctx, &existing, `SELECT submission_id,sender_id,subject,content,anonymous,attachment_file_ids,notice_version,status,submitted_at FROM im_mailbox_submissions WHERE tenant_id=? AND sender_id=? AND idempotency_key=? LIMIT 1`, s.tenant(), userID, idempotencyKey); getErr != nil {
|
|
return nil, getErr
|
|
}
|
|
return &existing, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
submission.SenderID = userID
|
|
submission.Status = "received"
|
|
submission.SubmittedAt = now
|
|
return &submission, nil
|
|
}
|
|
|
|
func (s *Store) attendanceRecordByDate(ctx context.Context, userID int64, date time.Time) (*AttendanceRecord, error) {
|
|
var record AttendanceRecord
|
|
if err := s.db.GetContext(ctx, &record, `SELECT record_id,user_id,record_date,rule_id,shift_id,status,exceptions,appeal_status,version,created_at,updated_at
|
|
FROM im_attendance_records WHERE tenant_id=? AND user_id=? AND record_date=? ORDER BY id DESC LIMIT 1`, s.tenant(), userID, date.Format("2006-01-02")); err != nil {
|
|
return nil, err
|
|
}
|
|
record.Checkins, _ = s.attendanceCheckins(ctx, record.RecordID)
|
|
return &record, nil
|
|
}
|
|
|
|
func (s *Store) attendanceCheckins(ctx context.Context, recordID string) ([]AttendanceCheckin, error) {
|
|
var checkins []AttendanceCheckin
|
|
err := s.db.SelectContext(ctx, &checkins, `SELECT checkin_id,action,client_time,server_time,result,location,wifi,device FROM im_attendance_checkins WHERE tenant_id=? AND record_id=? ORDER BY id ASC`, s.tenant(), recordID)
|
|
return checkins, err
|
|
}
|
|
|
|
func (s *Store) attendanceRecordAndCheckinByIdempotency(ctx context.Context, userID int64, key string) (*AttendanceRecord, *AttendanceCheckin, error) {
|
|
var checkin AttendanceCheckin
|
|
var recordID string
|
|
if err := s.db.QueryRowxContext(ctx, `SELECT checkin_id,record_id,action,client_time,server_time,result,location,wifi,device FROM im_attendance_checkins WHERE tenant_id=? AND user_id=? AND idempotency_key=? LIMIT 1`, s.tenant(), userID, key).
|
|
Scan(&checkin.CheckinID, &recordID, &checkin.Action, &checkin.ClientTime, &checkin.ServerTime, &checkin.Result, &checkin.Location, &checkin.Wifi, &checkin.Device); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var record AttendanceRecord
|
|
if err := s.db.GetContext(ctx, &record, `SELECT record_id,user_id,record_date,rule_id,shift_id,status,exceptions,appeal_status,version,created_at,updated_at FROM im_attendance_records WHERE tenant_id=? AND record_id=?`, s.tenant(), recordID); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
record.Checkins, _ = s.attendanceCheckins(ctx, recordID)
|
|
return &record, &checkin, nil
|
|
}
|
|
|
|
func (s *Store) meetingParticipants(ctx context.Context, meetingID string) ([]MeetingParticipant, error) {
|
|
var items []MeetingParticipant
|
|
err := s.db.SelectContext(ctx, &items, `SELECT meeting_id,user_id,response,response_at FROM im_meeting_participants WHERE tenant_id=? AND meeting_id=? ORDER BY id ASC`, s.tenant(), meetingID)
|
|
return items, err
|
|
}
|
|
|
|
func (s *Store) hydrateMeetingUsers(ctx context.Context, meeting *Meeting) error {
|
|
if meeting == nil {
|
|
return nil
|
|
}
|
|
seen := map[int64]bool{}
|
|
userIDs := make([]int64, 0, len(meeting.Participants)+2)
|
|
add := func(userID int64) {
|
|
if userID > 0 && !seen[userID] {
|
|
seen[userID] = true
|
|
userIDs = append(userIDs, userID)
|
|
}
|
|
}
|
|
add(meeting.OrganizerID)
|
|
add(meeting.RecorderID)
|
|
for _, participant := range meeting.Participants {
|
|
add(participant.UserID)
|
|
}
|
|
summaries, err := s.UserSummaries(ctx, userIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
meeting.Organizer = summaryOrDefault(meeting.OrganizerID, summaries)
|
|
meeting.Recorder = summaryOrDefault(meeting.RecorderID, summaries)
|
|
for i := range meeting.Participants {
|
|
meeting.Participants[i].User = summaryOrDefault(meeting.Participants[i].UserID, summaries)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) UserSummaries(ctx context.Context, userIDs []int64) (map[int64]UserSummary, error) {
|
|
result := make(map[int64]UserSummary, len(userIDs))
|
|
ids := uniquePositiveInt64s(userIDs)
|
|
if len(ids) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
query, args, err := sqlx.In(`SELECT user_id,display_name,IFNULL(avatar_file_id,'') AS avatar_file_id
|
|
FROM im_org_members WHERE tenant_id=? AND employment_state='active' AND user_id IN (?) ORDER BY user_id ASC,id ASC`, s.tenant(), ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var orgRows []struct {
|
|
UserID int64 `db:"user_id"`
|
|
DisplayName string `db:"display_name"`
|
|
AvatarFileID string `db:"avatar_file_id"`
|
|
}
|
|
if err := s.db.SelectContext(ctx, &orgRows, s.db.Rebind(query), args...); err != nil && !isMissingTable(err) {
|
|
return nil, err
|
|
}
|
|
for _, row := range orgRows {
|
|
if _, ok := result[row.UserID]; ok {
|
|
continue
|
|
}
|
|
result[row.UserID] = UserSummary{UserID: row.UserID, DisplayName: strings.TrimSpace(row.DisplayName), AvatarFileID: row.AvatarFileID}
|
|
}
|
|
|
|
query, args, err = sqlx.In(`SELECT user_id,nickname AS display_name,IFNULL(avatar_file_id,'') AS avatar_file_id
|
|
FROM im_user_profiles WHERE tenant_id=? AND user_id IN (?) ORDER BY user_id ASC`, s.tenant(), ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var profileRows []struct {
|
|
UserID int64 `db:"user_id"`
|
|
DisplayName string `db:"display_name"`
|
|
AvatarFileID string `db:"avatar_file_id"`
|
|
}
|
|
if err := s.db.SelectContext(ctx, &profileRows, s.db.Rebind(query), args...); err != nil && !isMissingTable(err) {
|
|
return nil, err
|
|
}
|
|
for _, row := range profileRows {
|
|
summary := result[row.UserID]
|
|
if summary.UserID == 0 {
|
|
summary.UserID = row.UserID
|
|
}
|
|
if summary.DisplayName == "" {
|
|
summary.DisplayName = strings.TrimSpace(row.DisplayName)
|
|
}
|
|
if summary.AvatarFileID == "" {
|
|
summary.AvatarFileID = row.AvatarFileID
|
|
}
|
|
result[row.UserID] = summary
|
|
}
|
|
|
|
query, args, err = sqlx.In(`SELECT id AS user_id,
|
|
COALESCE(
|
|
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.display_name')),''),
|
|
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.fn')),''),
|
|
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.nickname')),''),
|
|
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.name')),''),
|
|
''
|
|
) AS display_name
|
|
FROM im_users WHERE tenant_id=? AND id IN (?)`, s.tenant(), ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var userRows []struct {
|
|
UserID int64 `db:"user_id"`
|
|
DisplayName string `db:"display_name"`
|
|
}
|
|
if err := s.db.SelectContext(ctx, &userRows, s.db.Rebind(query), args...); err != nil && !isMissingTable(err) {
|
|
return nil, err
|
|
}
|
|
for _, row := range userRows {
|
|
summary := result[row.UserID]
|
|
if summary.UserID == 0 {
|
|
summary.UserID = row.UserID
|
|
}
|
|
if summary.DisplayName == "" {
|
|
summary.DisplayName = strings.TrimSpace(row.DisplayName)
|
|
}
|
|
result[row.UserID] = summary
|
|
}
|
|
|
|
for _, id := range ids {
|
|
result[id] = summaryOrDefault(id, result)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func uniquePositiveInt64s(values []int64) []int64 {
|
|
seen := map[int64]bool{}
|
|
out := make([]int64, 0, len(values))
|
|
for _, value := range values {
|
|
if value <= 0 || seen[value] {
|
|
continue
|
|
}
|
|
seen[value] = true
|
|
out = append(out, value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func summaryOrDefault(userID int64, summaries map[int64]UserSummary) UserSummary {
|
|
if userID <= 0 {
|
|
return UserSummary{}
|
|
}
|
|
summary := summaries[userID]
|
|
if summary.UserID == 0 {
|
|
summary.UserID = userID
|
|
}
|
|
return summary
|
|
}
|
|
|
|
func (s *Store) meetingNoteFile(ctx context.Context, meetingID, noteFileID string) (*MeetingNoteFile, error) {
|
|
var file MeetingNoteFile
|
|
if err := s.db.GetContext(ctx, &file, `SELECT note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,preview_file_id,version,created_at,updated_at
|
|
FROM im_meeting_note_files WHERE tenant_id=? AND meeting_id=? AND note_file_id=? LIMIT 1`, s.tenant(), meetingID, noteFileID); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if err := s.hydrateMeetingNoteFileUploader(ctx, &file); err != nil {
|
|
return nil, err
|
|
}
|
|
return &file, nil
|
|
}
|
|
|
|
func (s *Store) hydrateMeetingNoteFileUploader(ctx context.Context, file *MeetingNoteFile) error {
|
|
if file == nil {
|
|
return nil
|
|
}
|
|
summaries, err := s.UserSummaries(ctx, []int64{file.UploaderID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
file.Uploader = summaryOrDefault(file.UploaderID, summaries)
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) hydrateMeetingNoteFilesUploaders(ctx context.Context, files []MeetingNoteFile) error {
|
|
if len(files) == 0 {
|
|
return nil
|
|
}
|
|
userIDs := make([]int64, 0, len(files))
|
|
for _, file := range files {
|
|
userIDs = append(userIDs, file.UploaderID)
|
|
}
|
|
summaries, err := s.UserSummaries(ctx, userIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := range files {
|
|
files[i].Uploader = summaryOrDefault(files[i].UploaderID, summaries)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func meetingVisible(meeting Meeting, userID int64) bool {
|
|
if meeting.OrganizerID == userID || meeting.RecorderID == userID {
|
|
return true
|
|
}
|
|
for _, participant := range meeting.Participants {
|
|
if participant.UserID == userID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func trimDepartmentPage(items []OrganizationDepartment, limit int) ([]OrganizationDepartment, string, error) {
|
|
next := ""
|
|
if len(items) > limit {
|
|
next = items[limit-1].DepartmentID
|
|
items = items[:limit]
|
|
}
|
|
return items, next, nil
|
|
}
|
|
|
|
func trimMemberPage(items []OrganizationMember, limit int) ([]OrganizationMember, string, error) {
|
|
next := ""
|
|
if len(items) > limit {
|
|
next = fmt.Sprintf("%d", items[limit-1].UserID)
|
|
items = items[:limit]
|
|
}
|
|
return items, next, nil
|
|
}
|
|
|
|
func reportMemberScope(departmentID string) ([]string, []any) {
|
|
where := []string{"employment_state='active'"}
|
|
args := []any{}
|
|
if departmentID = strings.TrimSpace(departmentID); departmentID != "" {
|
|
where = append(where, "department_id=?")
|
|
args = append(args, departmentID)
|
|
}
|
|
return where, args
|
|
}
|
|
|
|
func prefixReportMemberScope(where []string) []string {
|
|
out := make([]string, 0, len(where))
|
|
for _, condition := range where {
|
|
condition = strings.TrimSpace(condition)
|
|
switch {
|
|
case strings.HasPrefix(condition, "tenant_id"):
|
|
out = append(out, "m."+condition)
|
|
case strings.HasPrefix(condition, "employment_state"):
|
|
out = append(out, "m."+condition)
|
|
case strings.HasPrefix(condition, "department_id"):
|
|
out = append(out, "m."+condition)
|
|
default:
|
|
out = append(out, condition)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isDuplicate(err error) bool {
|
|
return err != nil && strings.Contains(err.Error(), "Duplicate entry")
|
|
}
|
|
|
|
func isMissingTable(err error) bool {
|
|
return err != nil && strings.Contains(err.Error(), "doesn't exist")
|
|
}
|