Files
im_server/server/db/mysql/tenant_adapter.go
T

9994 lines
309 KiB
Go

//go:build mysql
// +build mysql
package mysql
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/jmoiron/sqlx"
"github.com/tinode/chat/server/db/common"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
)
func (*adapter) TenantBusinessReady() bool { return true }
func (a *adapter) routedTenantAdapter(id t.TenantID) (*adapter, error) {
if err := validTenant(id); err != nil {
return nil, err
}
if !a.tenantDBRouting {
return a, nil
}
business, _, err := a.TenantAdapterFor(id)
if err != nil {
return nil, err
}
tenantAdp, ok := business.(*adapter)
if !ok {
return nil, t.ErrUnsupported
}
return tenantAdp, nil
}
func stampTenantUser(id t.TenantID, user *t.User) *t.User {
if user != nil {
user.TenantID = id
}
return user
}
type profileUserRow struct {
TenantID t.TenantID `db:"tenant_id"`
ID string `db:"id"`
Username string
CreatedAt time.Time `db:"createdat"`
UpdatedAt time.Time `db:"updatedat"`
State t.ObjState `db:"state"`
StateAt *time.Time `db:"stateat"`
Access t.DefaultAccess
Phone string `db:"phone"`
LastSeen sql.NullTime `db:"lastseen"`
UserAgent string `db:"useragent"`
Public any `db:"public"`
Trusted any `db:"trusted"`
ProfileNickname sql.NullString `db:"profile_nickname"`
ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"`
ProfileSignature sql.NullString `db:"profile_signature"`
ProfileGender sql.NullString `db:"profile_gender"`
ProfileRegion sql.NullString `db:"profile_region"`
ProfilePublicAccount sql.NullString `db:"profile_public_account"`
ProfileUpdatedAt sql.NullTime `db:"profile_updated_at"`
}
func (row profileUserRow) effectiveUpdatedAt() time.Time {
if row.ProfileUpdatedAt.Valid {
return common.SelectLatestTime(row.UpdatedAt, row.ProfileUpdatedAt.Time)
}
return row.UpdatedAt
}
func (row profileUserRow) lastSeen() *time.Time {
if row.LastSeen.Valid {
return &row.LastSeen.Time
}
return nil
}
func (row profileUserRow) profilePublic() any {
return profilePublicFromFields(common.FromJSON(row.Public), row.ProfileNickname, row.ProfileAvatarFileID, row.ProfileSignature, row.ProfileGender, row.ProfileRegion, row.ProfilePublicAccount)
}
func profileUserFromRow(row profileUserRow) t.User {
user := t.User{
ObjHeader: t.ObjHeader{
Id: row.ID,
CreatedAt: row.CreatedAt,
UpdatedAt: row.effectiveUpdatedAt(),
},
TenantID: row.TenantID,
State: row.State,
StateAt: row.StateAt,
Username: row.Username,
Access: row.Access,
LastSeen: row.lastSeen(),
UserAgent: row.UserAgent,
Phone: row.Phone,
Public: row.profilePublic(),
Trusted: common.FromJSON(row.Trusted),
}
user.SetUid(common.EncodeUidString(row.ID))
return user
}
func profilePublicFromFields(base any, nickname, avatarFileID, signature, gender, region, publicAccount sql.NullString) any {
public := publicMap(base)
clearProfilePublicFields(public)
if nickname.Valid {
value := strings.TrimSpace(nickname.String)
if value != "" {
for _, key := range []string{"display_name", "nickname", "fn", "name"} {
public[key] = value
}
}
}
if avatarFileID.Valid {
value := strings.TrimSpace(avatarFileID.String)
if value != "" {
public["avatar_file_id"] = value
}
}
if signature.Valid {
if value := strings.TrimSpace(signature.String); value != "" {
public["signature"] = value
}
}
if gender.Valid {
if value := strings.TrimSpace(gender.String); value != "" {
public["gender"] = value
}
}
if region.Valid {
if value := strings.TrimSpace(region.String); value != "" {
public["region"] = value
}
}
if publicAccount.Valid {
if value := strings.TrimSpace(publicAccount.String); value != "" {
public["public_account"] = value
}
}
return public
}
func clearProfilePublicFields(public map[string]any) {
for _, key := range []string{"display_name", "nickname", "fn", "name", "avatar_file_id", "photo", "avatar", "signature", "gender", "region", "public_account"} {
delete(public, key)
}
}
func profileSeedFromPublic(public any) (nickname, avatarFileID string) {
values := publicMap(public)
for _, key := range []string{"display_name", "nickname", "fn", "name"} {
if value := strings.TrimSpace(fmt.Sprint(values[key])); value != "" && value != "<nil>" {
nickname = value
break
}
}
for _, key := range []string{"avatar_file_id"} {
if value := strings.TrimSpace(fmt.Sprint(values[key])); value != "" && value != "<nil>" {
avatarFileID = value
break
}
}
return nickname, avatarFileID
}
func publicMap(value any) map[string]any {
switch public := value.(type) {
case map[string]any:
out := make(map[string]any, len(public))
for key, val := range public {
out[key] = val
}
return out
case map[string]string:
out := make(map[string]any, len(public))
for key, val := range public {
out[key] = val
}
return out
default:
return map[string]any{}
}
}
func stampTenantUsers(id t.TenantID, users []t.User) []t.User {
for i := range users {
users[i].TenantID = id
}
return users
}
func stampTenantTopic(id t.TenantID, topic *t.Topic) *t.Topic {
if topic != nil {
topic.TenantID = id
}
return topic
}
func stampTenantSubs(id t.TenantID, subs []t.Subscription) []t.Subscription {
for i := range subs {
subs[i].TenantID = id
}
return subs
}
func stampTenantMessages(id t.TenantID, msgs []t.Message) []t.Message {
for i := range msgs {
msgs[i].TenantID = id
}
return msgs
}
func stampTenantMessage(id t.TenantID, msg *t.Message) *t.Message {
if msg != nil {
msg.TenantID = id
}
return msg
}
func finalizeTenantMessage(id t.TenantID, msg t.Message, recalledBy sql.NullInt64) t.Message {
msg.TenantID = id
msg.From = common.EncodeUidString(msg.From).String()
if recalledBy.Valid {
msg.RecalledBy = common.EncodeUidString(strconv.FormatInt(recalledBy.Int64, 10)).String()
}
msg.Content = common.FromJSON(msg.Content)
return msg
}
func normalizeGroupMessage(id t.TenantID, msg t.Message, recalledBy sql.NullInt64) t.Message {
msg.TenantID = id
msg.From = common.EncodeUidString(msg.From).String()
if recalledBy.Valid {
msg.RecalledBy = common.EncodeUidString(strconv.FormatInt(recalledBy.Int64, 10)).String()
}
msg.Content = common.FromJSON(msg.Content)
return msg
}
func messageHeadType(head t.KVMap) string {
if head == nil {
return "text"
}
for _, key := range []string{"x-im-type", "type"} {
if value, ok := head[key].(string); ok {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
}
return "text"
}
func messageHeadJSON(head t.KVMap, key string) []byte {
if head == nil {
return nil
}
if value, ok := head[key]; ok {
return common.ToJSON(value)
}
return nil
}
func messageHeadBool(head t.KVMap, key string) bool {
if head == nil {
return false
}
switch value := head[key].(type) {
case bool:
return value
case string:
parsed, _ := strconv.ParseBool(value)
return parsed
default:
return false
}
}
func stampTenantFile(id t.TenantID, file *t.FileDef) *t.FileDef {
if file != nil {
file.TenantID = id
}
return file
}
func validTenant(id t.TenantID) error {
if !id.IsValid() {
return t.ErrMalformed
}
return nil
}
func tenantCacheKey(id t.TenantID, key string) (string, error) {
if key == "" || strings.Contains(key, "%") {
return "", t.ErrMalformed
}
return "tenant:" + strconv.FormatInt(int64(id), 10) + ":" + key, nil
}
func decodeUID(uid t.Uid) int64 {
return store.DecodeUid(uid)
}
func encodeUserID(id int64) string {
return store.EncodeUid(id).String()
}
func scanTenantUser(user *t.User) {
user.SetUid(common.EncodeUidString(user.Id))
user.Public = common.FromJSON(user.Public)
user.Trusted = common.FromJSON(user.Trusted)
}
func scanTenantTopic(topic *t.Topic) {
topic.Owner = common.EncodeUidString(topic.Owner).String()
topic.Public = common.FromJSON(topic.Public)
topic.Trusted = common.FromJSON(topic.Trusted)
}
func (a *adapter) TenantUserCreate(id t.TenantID, user *t.User) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || user == nil || user.TenantID != id {
return t.ErrMalformed
}
if tenantAdp.tenantScopedDB {
return tenantAdp.UserCreate(user)
}
return tenantAdp.UserCreate(user)
}
func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
if tenantAdp != a {
user, err := tenantAdp.UserGet(uid)
return stampTenantUser(id, user), err
}
if a.tenantScopedDB {
user, err := a.UserGet(uid)
return stampTenantUser(id, user), err
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var row profileUserRow
err = a.db.GetContext(ctx, &row, `SELECT ? AS tenant_id,u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,u.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
WHERE u.id=? AND u.state!=?`,
id, decodeUID(uid), t.StateDeleted)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user := profileUserFromRow(row)
return &user, nil
}
func (a *adapter) TenantUserGetByPhone(id t.TenantID, phone string) (t.Uid, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || phone == "" {
return t.ZeroUid, t.ErrMalformed
}
if tenantAdp != a || a.tenantScopedDB {
ctx, cancel := tenantAdp.getContext()
if cancel != nil {
defer cancel()
}
var decoded int64
err = tenantAdp.db.GetContext(ctx, &decoded,
"SELECT id FROM im_users WHERE phone=? AND state!=?", phone, t.StateDeleted)
if err == sql.ErrNoRows {
return t.ZeroUid, nil
}
if err != nil {
return t.ZeroUid, err
}
return store.EncodeUid(decoded), nil
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var decoded int64
err = a.db.GetContext(ctx, &decoded,
"SELECT id FROM im_users WHERE tenant_id=? AND phone=? AND state!=?", id, phone, t.StateDeleted)
if err == sql.ErrNoRows {
return t.ZeroUid, nil
}
if err != nil {
return t.ZeroUid, err
}
return store.EncodeUid(decoded), nil
}
func (a *adapter) TenantUserGetAll(id t.TenantID, uids ...t.Uid) ([]t.User, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
users, err := tenantAdp.UserGetAll(uids...)
return stampTenantUsers(id, users), err
}
if a.tenantScopedDB {
users, err := a.UserGetAll(uids...)
return stampTenantUsers(id, users), err
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
return a.tenantUserGetAllTx(ctx, nil, id, uids...)
}
func (a *adapter) tenantUserGetAllTx(ctx context.Context, tx *sqlx.Tx, id t.TenantID, uids ...t.Uid) ([]t.User, error) {
if len(uids) == 0 {
return nil, nil
}
args := make([]any, 0, len(uids))
for _, uid := range uids {
if !uid.IsZero() {
args = append(args, decodeUID(uid))
}
}
if len(args) == 0 {
return nil, nil
}
var q string
var params []any
if a.tenantScopedDB {
q, params, _ = sqlx.In(`SELECT id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id IN (?) AND state!=?`, args, t.StateDeleted)
} else {
q, params, _ = sqlx.In(`SELECT tenant_id,id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE tenant_id=? AND id IN (?) AND state!=?`, id, args, t.StateDeleted)
}
query := a.db.Rebind(q)
var rows *sqlx.Rows
var err error
if tx != nil {
rows, err = tx.QueryxContext(ctx, query, params...)
} else {
rows, err = a.db.QueryxContext(ctx, query, params...)
}
if err != nil {
return nil, err
}
defer rows.Close()
var users []t.User
for rows.Next() {
var user t.User
if err = rows.StructScan(&user); err != nil {
return nil, err
}
scanTenantUser(&user)
users = append(users, user)
}
return users, rows.Err()
}
func (a *adapter) TenantUserDelete(id t.TenantID, uid t.Uid, hard bool) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.UserDelete(uid, hard)
}
if a.tenantScopedDB {
return a.UserDelete(uid, hard)
}
decoded := decodeUID(uid)
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
var ownTopics []string
q := "SELECT name FROM im_topics WHERE tenant_id=? AND owner=?"
args := []any{id, decoded}
if !hard {
q += " AND state!=?"
args = append(args, t.StateDeleted)
}
if err = tx.Select(&ownTopics, q, args...); err != nil {
return err
}
var ownAll []any
for _, name := range ownTopics {
ownAll = append(ownAll, name)
if chn := t.GrpToChn(name); chn != "" {
ownAll = append(ownAll, chn)
}
}
now := t.TimeNow()
if hard {
if _, err = tx.Exec("DELETE FROM im_devices WHERE tenant_id=? AND userid=?", id, decoded); err != nil {
return err
}
if err = tenantSubsDelForUser(tx, id, decoded, true); err != nil {
return err
}
if _, err = tx.Exec("DELETE FROM im_dellog WHERE tenant_id=? AND deletedfor=?", id, decoded); err != nil {
return err
}
if len(ownTopics) > 0 {
if _, err = tx.Exec("DELETE dl FROM im_dellog AS dl JOIN im_topics AS tp ON tp.tenant_id=dl.tenant_id AND tp.name=dl.topic WHERE tp.tenant_id=? AND tp.owner=?", id, decoded); err != nil {
return err
}
if _, err = tx.Exec("DELETE m FROM im_messages AS m JOIN im_topics AS tp ON tp.tenant_id=m.tenant_id AND tp.name=m.topic WHERE tp.tenant_id=? AND tp.owner=?", id, decoded); err != nil {
return err
}
sqlq, params, _ := sqlx.In("DELETE FROM im_subscriptions WHERE tenant_id=? AND topic IN (?)", id, ownAll)
if _, err = tx.Exec(tx.Rebind(sqlq), params...); err != nil {
return err
}
if _, err = tx.Exec("DELETE tt FROM im_topictags AS tt JOIN im_topics AS tp ON tp.tenant_id=tt.tenant_id AND tp.name=tt.topic WHERE tp.tenant_id=? AND tp.owner=?", id, decoded); err != nil {
return err
}
if _, err = tx.Exec("DELETE FROM im_topics WHERE tenant_id=? AND owner=?", id, decoded); err != nil {
return err
}
}
if _, err = tx.Exec("DELETE FROM im_auth WHERE userid=?", decoded); err != nil {
return err
}
if _, err = tx.Exec("DELETE FROM im_credentials WHERE userid=?", decoded); err != nil {
return err
}
if _, err = tx.Exec("DELETE FROM im_users WHERE tenant_id=? AND id=?", id, decoded); err != nil {
return err
}
} else {
if err = tenantSubsDelForUser(tx, id, decoded, false); err != nil {
return err
}
if len(ownAll) > 0 {
sqlq, params, _ := sqlx.In("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic IN (?)", now, now, id, ownAll)
if _, err = tx.Exec(tx.Rebind(sqlq), params...); err != nil {
return err
}
}
if _, err = tx.Exec("UPDATE im_topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE tenant_id=? AND owner=?",
now, now, t.StateDeleted, now, id, decoded); err != nil {
return err
}
if _, err = tx.Exec("UPDATE im_topics AS tp JOIN im_subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+
"SET tp.updatedat=?,tp.touchedat=?,tp.state=?,tp.stateat=? WHERE tp.tenant_id=? AND tp.owner=0 AND s.userid=? AND tp.name LIKE 'p2p%'",
now, now, t.StateDeleted, now, id, decoded); err != nil {
return err
}
if _, err = tx.Exec("UPDATE im_subscriptions AS s_one JOIN im_subscriptions AS s_two ON s_one.tenant_id=s_two.tenant_id AND s_one.topic=s_two.topic "+
"SET s_two.updatedat=?,s_two.deletedat=? WHERE s_one.tenant_id=? AND s_one.userid=? AND s_one.topic LIKE 'p2p%'",
now, now, id, decoded); err != nil {
return err
}
if _, err = tx.Exec("UPDATE im_users SET updatedat=?,state=?,stateat=? WHERE tenant_id=? AND id=?",
now, t.StateDeleted, now, id, decoded); err != nil {
return err
}
}
return tx.Commit()
}
func (a *adapter) tenantTopicStateForUser(tx *sqlx.Tx, tenantID t.TenantID, decodedUID int64, now time.Time, state any) error {
objState, ok := state.(t.ObjState)
if !ok {
return t.ErrMalformed
}
if now.IsZero() {
now = t.TimeNow()
}
if _, err := tx.Exec("UPDATE im_topics SET state=?,stateat=? WHERE tenant_id=? AND owner=? AND state!=?",
objState, now, tenantID, decodedUID, t.StateDeleted); err != nil {
return err
}
_, err := tx.Exec("UPDATE im_topics AS tp JOIN im_subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+
"SET tp.state=?,tp.stateat=? WHERE tp.tenant_id=? AND tp.owner=0 AND s.userid=? AND tp.state!=?",
objState, now, tenantID, decodedUID, t.StateDeleted)
return err
}
func (a *adapter) TenantUserUpdate(id t.TenantID, uid t.Uid, update map[string]any) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.UserUpdate(uid, update)
}
if a.tenantScopedDB {
return a.UserUpdate(uid, update)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
decoded := decodeUID(uid)
update = withoutUserTags(update)
cols, args := common.UpdateByMap(update)
if len(cols) == 0 {
return tx.Commit()
}
args = append(args, id, decoded)
res, err := tx.Exec("UPDATE im_users SET "+strings.Join(cols, ",")+" WHERE tenant_id=? AND id=?", args...)
if err != nil {
return err
}
if count, _ := res.RowsAffected(); count == 0 {
return t.ErrNotFound
}
if state, ok := update["State"]; ok {
now, _ := update["StateAt"].(time.Time)
if err = a.tenantTopicStateForUser(tx, id, decoded, now, state); err != nil {
return err
}
}
return tx.Commit()
}
func (a *adapter) TenantUserUpdateTags(id t.TenantID, uid t.Uid, add, remove, reset []string) ([]string, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.UserUpdateTags(uid, add, remove, reset)
}
if a.tenantScopedDB {
return a.UserUpdateTags(uid, add, remove, reset)
}
return nil, nil
}
func (a *adapter) TenantUserUnreadCount(id t.TenantID, uids ...t.Uid) (map[t.Uid]int, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.UserUnreadCount(uids...)
}
if a.tenantScopedDB {
return a.UserUnreadCount(uids...)
}
counts := make(map[t.Uid]int, len(uids))
var decoded []any
for _, uid := range uids {
counts[uid] = 0
if !uid.IsZero() {
decoded = append(decoded, decodeUID(uid))
}
}
if len(decoded) == 0 {
return counts, nil
}
q, args, _ := sqlx.In("SELECT s.userid,SUM(tp.seqid)-SUM(s.readseqid) AS unreadcount "+
"FROM im_topics AS tp JOIN im_subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+
"WHERE s.tenant_id=? AND s.userid IN (?) AND s.deletedat IS NULL AND tp.state!=? "+
"AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0 GROUP BY s.userid", id, decoded, t.StateDeleted)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), args...)
if err != nil {
return counts, err
}
defer rows.Close()
for rows.Next() {
var uid int64
var unread int
if err = rows.Scan(&uid, &unread); err != nil {
return counts, err
}
counts[store.EncodeUid(uid)] = unread
}
return counts, rows.Err()
}
func (a *adapter) TenantUserGetUnvalidated(id t.TenantID, before time.Time, limit int) ([]t.Uid, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.UserGetUnvalidated(before, limit)
}
if a.tenantScopedDB {
return a.UserGetUnvalidated(before, limit)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx,
"SELECT u.id,IFNULL(SUM(c.done),0) AS total FROM im_users AS u "+
"LEFT JOIN im_credentials AS c ON u.id=c.userid "+
"WHERE u.lastseen IS NULL AND u.updatedat<? "+
"GROUP BY u.id,u.updatedat HAVING total=0 ORDER BY u.updatedat ASC LIMIT ?", before, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var uids []t.Uid
for rows.Next() {
var userID int64
var unused int
if err = rows.Scan(&userID, &unused); err != nil {
return nil, err
}
uids = append(uids, store.EncodeUid(userID))
}
return uids, rows.Err()
}
func tenantTopicCreate(tx *sqlx.Tx, topic *t.Topic) error {
if topic == nil || topic.TenantID.IsZero() {
return t.ErrMalformed
}
_, err := tx.Exec("INSERT INTO im_topics(tenant_id,createdat,updatedat,touchedat,state,name,usebt,owner,access,public,trusted,tags,aux) "+
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
topic.TenantID, topic.CreatedAt, topic.UpdatedAt, topic.TouchedAt, topic.State, topic.Id, topic.UseBt,
decodeUID(t.ParseUid(topic.Owner)), topic.Access, common.ToJSON(topic.Public), common.ToJSON(topic.Trusted),
topic.Tags, common.ToJSON(topic.Aux))
if err != nil {
return err
}
return addTenantTags(tx, topic.TenantID, "im_topictags", "topic", topic.Id, topic.Tags, false)
}
func (a *adapter) TenantTopicCreate(id t.TenantID, topic *t.Topic) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == nil || topic.TenantID != id {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TopicCreate(topic)
}
if a.tenantScopedDB {
return a.TopicCreate(topic)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if err = tenantTopicCreate(tx, topic); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantTopicCreateP2P(id t.TenantID, one, two *t.Subscription) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || one == nil || two == nil || one.TenantID != id || two.TenantID != id {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TopicCreateP2P(one, two)
}
if a.tenantScopedDB {
return a.TopicCreateP2P(one, two)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if err = createSubscription(tx, one, false); err != nil {
return err
}
if err = createSubscription(tx, two, true); err != nil {
return err
}
topic := &t.Topic{TenantID: id, ObjHeader: t.ObjHeader{Id: one.Topic}}
topic.ObjHeader.MergeTimes(&one.ObjHeader)
topic.TouchedAt = one.GetTouchedAt()
var existingTopicID int64
err = tx.Get(&existingTopicID, "SELECT id FROM im_topics WHERE name=? ORDER BY id ASC LIMIT 1", topic.Id)
if err != nil && err != sql.ErrNoRows {
return err
}
if err == sql.ErrNoRows {
if err = tenantTopicCreate(tx, topic); err != nil {
return err
}
} else if _, err = tx.ExecContext(ctx, "UPDATE im_topics SET updatedat=?,touchedat=?,state=? WHERE id=?",
topic.UpdatedAt, topic.TouchedAt, t.StateOK, existingTopicID); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantTopicGet(id t.TenantID, topic string) (*t.Topic, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return nil, t.ErrMalformed
}
if tenantAdp != a {
top, err := tenantAdp.TopicGet(topic)
return stampTenantTopic(id, top), err
}
if a.tenantScopedDB {
top, err := a.TopicGet(topic)
return stampTenantTopic(id, top), err
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var top t.Topic
err = a.db.GetContext(ctx, &top,
"SELECT tenant_id,createdat,updatedat,state,stateat,touchedat,name AS id,usebt,access,owner,seqid,delid,subcnt,public,trusted,tags,aux "+
"FROM im_topics WHERE tenant_id=? AND name=?", id, topic)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if t.GetTopicCat(topic) == t.TopicCatGrp {
var subCnt int
if err = a.db.GetContext(ctx, &subCnt,
"SELECT COUNT(*) FROM im_subscriptions WHERE tenant_id=? AND topic IN (?,?) AND deletedat IS NULL",
id, topic, t.GrpToChn(topic)); err != nil {
return nil, err
}
if subCnt != top.SubCnt {
top.SubCnt = subCnt
if _, err = a.db.ExecContext(ctx, "UPDATE im_topics SET subcnt=? WHERE tenant_id=? AND name=?", subCnt, id, topic); err != nil {
return nil, err
}
}
}
scanTenantTopic(&top)
return &top, nil
}
func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
if tenantAdp != a {
subs, err := tenantAdp.TopicsForUser(uid, keepDeleted, opts)
return stampTenantSubs(id, subs), err
}
if a.tenantScopedDB {
subs, err := a.TopicsForUser(uid, keepDeleted, opts)
return stampTenantSubs(id, subs), err
}
q := `SELECT tenant_id,createdat,updatedat,deletedat,topic,delid,recvseqid,
readseqid,modewant,modegiven,private FROM im_subscriptions WHERE tenant_id=? AND userid=?`
args := []any{id, decodeUID(uid)}
if !keepDeleted {
q += " AND deletedat IS NULL"
}
limit := 0
ims := time.Time{}
if opts != nil {
if opts.Topic != "" {
q += " AND topic=?"
args = append(args, opts.Topic)
}
if opts.IfModifiedSince == nil {
limit = a.maxResults
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
} else {
ims = *opts.IfModifiedSince
}
} else {
limit = a.maxResults
}
if limit > 0 {
q += " LIMIT ?"
args = append(args, limit)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, q, args...)
if err != nil {
return nil, err
}
join := map[string]t.Subscription{}
var topq, usrq []any
for rows.Next() {
var sub t.Subscription
if err = rows.StructScan(&sub); err != nil {
rows.Close()
return nil, err
}
tname := sub.Topic
sub.User = uid.String()
switch t.GetTopicCat(tname) {
case t.TopicCatMe, t.TopicCatFnd:
continue
case t.TopicCatP2P:
uid1, uid2, _ := t.ParseP2P(tname)
if uid1 == uid {
usrq = append(usrq, decodeUID(uid2))
sub.SetWith(uid2.UserId())
} else {
usrq = append(usrq, decodeUID(uid1))
sub.SetWith(uid1.UserId())
}
case t.TopicCatGrp:
tname = t.ChnToGrp(tname)
}
topq = append(topq, tname)
sub.Private = common.FromJSON(sub.Private)
join[tname] = sub
}
err = rows.Err()
rows.Close()
if err != nil || len(join) == 0 {
return nil, err
}
if len(topq) > 0 {
q = "SELECT tenant_id,updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,public,trusted FROM im_topics WHERE tenant_id=? AND name IN (?)"
q, args, _ = sqlx.In(q, id, topq)
if !keepDeleted {
q += " AND state!=?"
args = append(args, t.StateDeleted)
}
if !ims.IsZero() {
q += " AND touchedat>?"
args = append(args, ims)
if limit > 0 && limit < len(topq) {
q += " ORDER BY touchedat LIMIT ?"
args = append(args, limit)
}
}
rows, err = a.db.QueryxContext(ctx, a.db.Rebind(q), args...)
if err != nil {
return nil, err
}
for rows.Next() {
var top t.Topic
if err = rows.StructScan(&top); err != nil {
rows.Close()
return nil, err
}
sub := join[top.Id]
sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, top.UpdatedAt)
sub.SetState(top.State)
sub.SetTouchedAt(top.TouchedAt)
sub.SetSeqId(top.SeqId)
if t.GetTopicCat(sub.Topic) == t.TopicCatGrp {
sub.SetSubCnt(top.SubCnt)
sub.SetPublic(common.FromJSON(top.Public))
sub.SetTrusted(common.FromJSON(top.Trusted))
}
join[top.Id] = sub
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
}
if len(usrq) > 0 {
q = `SELECT u.tenant_id,u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,u.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id WHERE u.tenant_id=? AND u.id IN (?)`
q, args, _ = sqlx.In(q, id, usrq)
if !keepDeleted {
q += " AND u.state!=?"
args = append(args, t.StateDeleted)
}
rows, err = a.db.QueryxContext(ctx, a.db.Rebind(q), args...)
if err != nil {
return nil, err
}
for rows.Next() {
var user profileUserRow
if err = rows.StructScan(&user); err != nil {
rows.Close()
return nil, err
}
joinOn := uid.P2PName(common.EncodeUidString(user.ID))
if sub, ok := join[joinOn]; ok {
sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, user.effectiveUpdatedAt())
sub.SetState(user.State)
sub.SetPublic(user.profilePublic())
sub.SetTrusted(common.FromJSON(user.Trusted))
sub.SetDefaultAccess(user.Access.Auth, user.Access.Anon)
sub.SetLastSeenAndUA(user.lastSeen(), user.UserAgent)
join[joinOn] = sub
}
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
}
subs := make([]t.Subscription, 0, len(join))
for _, sub := range join {
subs = append(subs, sub)
}
return common.SelectEarliestUpdatedSubs(subs, opts, a.maxResults), nil
}
func (a *adapter) TenantUsersForTopic(id t.TenantID, topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return nil, t.ErrMalformed
}
if tenantAdp != a {
subs, err := tenantAdp.UsersForTopic(topic, keepDeleted, opts)
return stampTenantSubs(id, subs), err
}
if a.tenantScopedDB {
subs, err := a.UsersForTopic(topic, keepDeleted, opts)
return stampTenantSubs(id, subs), err
}
tcat := t.GetTopicCat(topic)
q := `SELECT s.tenant_id,s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid,
s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account
FROM im_subscriptions AS s JOIN im_users AS u ON s.tenant_id=u.tenant_id AND s.userid=u.id
LEFT JOIN im_user_profiles AS p ON p.tenant_id=s.tenant_id AND p.user_id=s.userid
WHERE s.tenant_id=? AND s.topic=?`
args := []any{id, topic}
if !keepDeleted {
q += " AND u.state!=?"
args = append(args, t.StateDeleted)
if tcat != t.TopicCatP2P {
q += " AND s.deletedat IS NULL"
}
}
limit := a.maxResults
var oneUser t.Uid
if opts != nil {
if !opts.User.IsZero() {
if tcat != t.TopicCatP2P {
q += " AND s.userid=?"
args = append(args, decodeUID(opts.User))
}
oneUser = opts.User
}
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
}
q += " LIMIT ?"
args = append(args, limit)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var subs []t.Subscription
var userAgent string
for rows.Next() {
var sub t.Subscription
var lastSeen sql.NullTime
var public, trusted any
var profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount sql.NullString
if err = rows.Scan(&sub.TenantID, &sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt,
&sub.User, &sub.Topic, &sub.DelId, &sub.RecvSeqId, &sub.ReadSeqId, &sub.ModeWant, &sub.ModeGiven,
&public, &trusted, &lastSeen, &userAgent, &sub.Private,
&profileNickname, &profileAvatarFileID, &profileSignature,
&profileGender, &profileRegion, &profilePublicAccount); err != nil {
return nil, err
}
sub.User = common.EncodeUidString(sub.User).String()
sub.Private = common.FromJSON(sub.Private)
sub.SetPublic(profilePublicFromFields(common.FromJSON(public), profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount))
sub.SetTrusted(common.FromJSON(trusted))
sub.SetLastSeenAndUA(&lastSeen.Time, userAgent)
subs = append(subs, sub)
}
if err = rows.Err(); err != nil {
return nil, err
}
if tcat == t.TopicCatP2P && len(subs) > 0 {
if len(subs) == 1 {
subs[0].SetPublic(nil)
subs[0].SetTrusted(nil)
subs[0].SetLastSeenAndUA(nil, "")
} else {
pub := subs[0].GetPublic()
subs[0].SetPublic(subs[1].GetPublic())
subs[1].SetPublic(pub)
trusted := subs[0].GetTrusted()
subs[0].SetTrusted(subs[1].GetTrusted())
subs[1].SetTrusted(trusted)
lastSeen := subs[0].GetLastSeen()
ua := subs[0].GetUserAgent()
subs[0].SetLastSeenAndUA(subs[1].GetLastSeen(), subs[1].GetUserAgent())
subs[1].SetLastSeenAndUA(lastSeen, ua)
}
if !keepDeleted || !oneUser.IsZero() {
filtered := subs[:0]
for i := range subs {
if (subs[i].DeletedAt != nil && !keepDeleted) || (!oneUser.IsZero() && subs[i].Uid() != oneUser) {
continue
}
filtered = append(filtered, subs[i])
}
subs = filtered
}
}
return subs, nil
}
func (a *adapter) tenantTopicNames(query string, args ...any) ([]string, error) {
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if err = rows.Scan(&name); err != nil {
return nil, err
}
names = append(names, name)
}
return names, rows.Err()
}
func (a *adapter) TenantOwnTopics(id t.TenantID, uid t.Uid) ([]string, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.OwnTopics(uid)
}
if a.tenantScopedDB {
return a.OwnTopics(uid)
}
return a.tenantTopicNames("SELECT name FROM im_topics WHERE tenant_id=? AND owner=? AND state!=?",
id, decodeUID(uid), t.StateDeleted)
}
func (a *adapter) TenantChannelsForUser(id t.TenantID, uid t.Uid) ([]string, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.ChannelsForUser(uid)
}
if a.tenantScopedDB {
return a.ChannelsForUser(uid)
}
return a.tenantTopicNames("SELECT topic FROM im_subscriptions WHERE tenant_id=? AND userid=? AND topic LIKE 'chn%' "+
"AND INSTR(modewant,'P')>0 AND INSTR(modegiven,'P')>0 AND deletedat IS NULL", id, decodeUID(uid))
}
func (a *adapter) TenantTopicShare(id t.TenantID, topic string, subs []*t.Subscription) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
for _, sub := range subs {
if sub == nil || sub.TenantID != id {
return t.ErrMalformed
}
}
if tenantAdp != a {
return tenantAdp.TopicShare(topic, subs)
}
if a.tenantScopedDB {
return a.TopicShare(topic, subs)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
for _, sub := range subs {
if err = createSubscription(tx, sub, true); err != nil {
return err
}
}
if topic != "" {
if _, err = tx.Exec("UPDATE im_topics SET subcnt=subcnt+? WHERE tenant_id=? AND name=?", len(subs), id, topic); err != nil {
return err
}
}
return tx.Commit()
}
func tenantMessageDeleteList(tx *sqlx.Tx, tenantID t.TenantID, topic string, toDel *t.DelMessage) error {
if toDel == nil {
if _, err := tx.Exec("DELETE FROM im_dellog WHERE tenant_id=? AND topic=?", tenantID, topic); err != nil {
return err
}
_, err := tx.Exec("DELETE FROM im_messages WHERE tenant_id=? AND topic=?", tenantID, topic)
return err
}
delRanges := toDel.SeqIdRanges
if toDel.DeletedFor == "" {
where := "m.tenant_id=? AND m.topic=?"
args := []any{tenantID, topic}
if len(delRanges) > 0 {
rSQL, rArgs := common.RangesToSql(delRanges)
where += " AND m.seqid " + rSQL
args = append(args, rArgs...)
}
where += " AND m.deletedat IS NULL"
if newerThan := toDel.GetNewerThan(); newerThan != nil {
where += " AND m.createdat>?"
args = append(args, newerThan)
}
var seqIDs []int64
if err := tx.Select(&seqIDs, "SELECT seqid FROM im_messages AS m WHERE "+where, args...); err != nil {
return err
}
if len(seqIDs) == 0 {
return nil
}
sort.Slice(seqIDs, func(i, j int) bool { return seqIDs[i] < seqIDs[j] })
delRanges = t.SliceToRanges(seqIDs)
rSQL, rArgs := common.RangesToSql(delRanges)
where = "m.tenant_id=? AND m.topic=? AND m.seqid " + rSQL
args = append([]any{tenantID, topic}, rArgs...)
if _, err := tx.Exec("DELETE fml.* FROM im_filemsglinks AS fml INNER JOIN im_messages AS m ON m.tenant_id=fml.tenant_id AND m.id=fml.msgid WHERE "+where, args...); err != nil {
return err
}
if _, err := tx.Exec("UPDATE im_messages AS m SET m.deletedat=?,m.delid=?,m.`from`=0,m.head=NULL,m.content=NULL WHERE "+
where, append([]any{t.TimeNow(), toDel.DelId}, args...)...); err != nil {
return err
}
}
stmt, err := tx.Prepare("INSERT INTO im_dellog(tenant_id,topic,deletedfor,delid,low,hi) VALUES(?,?,?,?,?,?)")
if err != nil {
return err
}
defer stmt.Close()
forUser := common.DecodeUidString(toDel.DeletedFor)
for _, rng := range delRanges {
if rng.Hi == 0 {
rng.Hi = rng.Low + 1
}
if _, err = stmt.Exec(tenantID, topic, forUser, toDel.DelId, rng.Low, rng.Hi); err != nil {
return err
}
}
return nil
}
func groupMessageDeleteList(tx *sqlx.Tx, topic string, toDel *t.DelMessage) error {
var err error
if toDel == nil {
if _, err = tx.Exec("DELETE FROM im_dellog WHERE topic=?", topic); err != nil {
return err
}
_, err = tx.Exec("DELETE FROM im_group_messages WHERE topic=?", topic)
return err
}
delRanges := toDel.SeqIdRanges
if toDel.DeletedFor == "" {
where := "m.topic=?"
args := []any{topic}
if len(delRanges) > 0 {
rSQL, rArgs := common.RangesToSql(delRanges)
where += " AND m.seqid " + rSQL
args = append(args, rArgs...)
}
where += " AND m.deleted_at IS NULL"
if newerThan := toDel.GetNewerThan(); newerThan != nil {
where += " AND m.created_at>?"
args = append(args, newerThan)
}
var seqIDs []int64
if err = tx.Select(&seqIDs, "SELECT seqid FROM im_group_messages AS m WHERE "+where, args...); err != nil {
return err
}
if len(seqIDs) == 0 {
return nil
}
sort.Slice(seqIDs, func(i, j int) bool { return seqIDs[i] < seqIDs[j] })
delRanges = t.SliceToRanges(seqIDs)
rSQL, rArgs := common.RangesToSql(delRanges)
where = "m.topic=? AND m.seqid " + rSQL
args = append([]any{topic}, rArgs...)
if _, err = tx.Exec("UPDATE im_group_messages AS m SET m.updated_at=?,m.deleted_at=?,m.status='deleted',m.from_user=0,m.head=NULL,m.content=NULL,m.content_size=0 WHERE "+
where, append([]any{t.TimeNow(), t.TimeNow()}, args...)...); err != nil {
return err
}
}
stmt, err := tx.Prepare("INSERT INTO im_dellog(topic,deletedfor,delid,low,hi) VALUES(?,?,?,?,?)")
if err != nil {
return err
}
defer stmt.Close()
forUser := common.DecodeUidString(toDel.DeletedFor)
for _, rng := range delRanges {
if rng.Hi == 0 {
rng.Hi = rng.Low + 1
}
if _, err = stmt.Exec(topic, forUser, toDel.DelId, rng.Low, rng.Hi); err != nil {
return err
}
}
return nil
}
func (a *adapter) TenantTopicDelete(id t.TenantID, topic string, isChan, hard bool) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TopicDelete(topic, isChan, hard)
}
if a.tenantScopedDB {
return a.TopicDelete(topic, isChan, hard)
}
topics := []any{topic}
if isChan {
topics = append(topics, t.GrpToChn(topic))
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if hard {
q, args, _ := sqlx.In("DELETE FROM im_subscriptions WHERE tenant_id=? AND topic IN (?)", id, topics)
if _, err = tx.Exec(tx.Rebind(q), args...); err != nil {
return err
}
if t.GetTopicCat(topic) == t.TopicCatGrp {
err = groupMessageDeleteList(tx, topic, nil)
} else {
err = tenantMessageDeleteList(tx, id, topic, nil)
}
if err != nil {
return err
}
if _, err = tx.Exec("DELETE FROM im_topictags WHERE tenant_id=? AND topic=?", id, topic); err != nil {
return err
}
if _, err = tx.Exec("DELETE FROM im_topics WHERE tenant_id=? AND name=?", id, topic); err != nil {
return err
}
} else {
now := t.TimeNow()
q, args, _ := sqlx.In("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic IN (?)", now, now, id, topics)
if _, err = tx.Exec(tx.Rebind(q), args...); err != nil {
return err
}
if _, err = tx.Exec("UPDATE im_topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE tenant_id=? AND name=?",
now, now, t.StateDeleted, now, id, topic); err != nil {
return err
}
}
return tx.Commit()
}
func (a *adapter) TenantTopicUpdateOnMessage(id t.TenantID, topic string, msg *t.Message) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || msg == nil || msg.TenantID != id {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TopicUpdateOnMessage(topic, msg)
}
if a.tenantScopedDB {
return a.TopicUpdateOnMessage(topic, msg)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx, "UPDATE im_topics SET seqid=?,touchedat=? WHERE tenant_id=? AND name=?",
msg.SeqId, msg.CreatedAt, id, topic)
return err
}
func (a *adapter) TenantTopicUpdateSubCnt(id t.TenantID, topic string) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TopicUpdateSubCnt(topic)
}
if a.tenantScopedDB {
return a.TopicUpdateSubCnt(topic)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx,
"UPDATE im_topics SET subcnt=(SELECT COUNT(*) FROM im_subscriptions WHERE tenant_id=? AND topic IN (?,?) AND deletedat IS NULL) WHERE tenant_id=? AND name=?",
id, topic, t.GrpToChn(topic), id, topic)
return err
}
func (a *adapter) TenantTopicUpdate(id t.TenantID, topic string, update map[string]any) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TopicUpdate(topic, update)
}
if a.tenantScopedDB {
return a.TopicUpdate(topic, update)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if update["TouchedAt"] == nil && update["UpdatedAt"] != nil {
update["TouchedAt"] = update["UpdatedAt"]
}
cols, args := common.UpdateByMap(update)
args = append(args, id, topic)
res, err := tx.Exec("UPDATE im_topics SET "+strings.Join(cols, ",")+" WHERE tenant_id=? AND name=?", args...)
if err != nil {
return err
}
if count, _ := res.RowsAffected(); count == 0 {
return t.ErrNotFound
}
if tags := common.ExtractTags(update); tags != nil {
if _, err = tx.Exec("DELETE FROM im_topictags WHERE tenant_id=? AND topic=?", id, topic); err != nil {
return err
}
if err = addTenantTags(tx, id, "im_topictags", "topic", topic, tags, false); err != nil {
return err
}
}
return tx.Commit()
}
func (a *adapter) TenantTopicOwnerChange(id t.TenantID, topic string, owner t.Uid) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TopicOwnerChange(topic, owner)
}
if a.tenantScopedDB {
return a.TopicOwnerChange(topic, owner)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx, "UPDATE im_topics SET owner=? WHERE tenant_id=? AND name=?", decodeUID(owner), id, topic)
return err
}
func (a *adapter) TenantSubscriptionGet(id t.TenantID, topic string, uid t.Uid, keepDeleted bool) (*t.Subscription, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" || uid.IsZero() {
return nil, t.ErrMalformed
}
if tenantAdp != a {
sub, err := tenantAdp.SubscriptionGet(topic, uid, keepDeleted)
if sub != nil {
sub.TenantID = id
}
return sub, err
}
if a.tenantScopedDB {
sub, err := a.SubscriptionGet(topic, uid, keepDeleted)
if sub != nil {
sub.TenantID = id
}
return sub, err
}
query := `SELECT tenant_id,createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,
readseqid,modewant,modegiven,private FROM im_subscriptions WHERE tenant_id=? AND topic=? AND userid=?`
if !keepDeleted {
query += " AND deletedat IS NULL"
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var sub t.Subscription
err = a.db.GetContext(ctx, &sub, query, id, topic, decodeUID(uid))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
sub.User = uid.String()
sub.Private = common.FromJSON(sub.Private)
return &sub, nil
}
func (a *adapter) TenantSubsForUser(id t.TenantID, uid t.Uid) ([]t.Subscription, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
if tenantAdp != a {
subs, err := tenantAdp.SubsForUser(uid)
return stampTenantSubs(id, subs), err
}
if a.tenantScopedDB {
subs, err := a.SubsForUser(uid)
return stampTenantSubs(id, subs), err
}
rows, err := a.db.Queryx(`SELECT tenant_id,createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,
readseqid,modewant,modegiven FROM im_subscriptions WHERE tenant_id=? AND userid=? AND deletedat IS NULL`,
id, decodeUID(uid))
if err != nil {
return nil, err
}
defer rows.Close()
var subs []t.Subscription
for rows.Next() {
var sub t.Subscription
if err = rows.StructScan(&sub); err != nil {
return nil, err
}
sub.User = uid.String()
subs = append(subs, sub)
}
return subs, rows.Err()
}
func (a *adapter) TenantSubsForTopic(id t.TenantID, topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return nil, t.ErrMalformed
}
if tenantAdp != a {
subs, err := tenantAdp.SubsForTopic(topic, keepDeleted, opts)
return stampTenantSubs(id, subs), err
}
if a.tenantScopedDB {
subs, err := a.SubsForTopic(topic, keepDeleted, opts)
return stampTenantSubs(id, subs), err
}
q := `SELECT tenant_id,createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,
readseqid,modewant,modegiven,private FROM im_subscriptions WHERE tenant_id=? AND topic=?`
args := []any{id, topic}
if !keepDeleted {
q += " AND deletedat IS NULL"
}
limit := a.maxResults
if opts != nil {
if !opts.User.IsZero() {
q += " AND userid=?"
args = append(args, decodeUID(opts.User))
}
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
}
q += " LIMIT ?"
args = append(args, limit)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var subs []t.Subscription
for rows.Next() {
var sub t.Subscription
if err = rows.StructScan(&sub); err != nil {
return nil, err
}
sub.User = common.EncodeUidString(sub.User).String()
sub.Private = common.FromJSON(sub.Private)
subs = append(subs, sub)
}
return subs, rows.Err()
}
func (a *adapter) TenantSubsUpdate(id t.TenantID, topic string, uid t.Uid, update map[string]any) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.SubsUpdate(topic, uid, update)
}
if a.tenantScopedDB {
return a.SubsUpdate(topic, uid, update)
}
cols, args := common.UpdateByMap(update)
q := "UPDATE im_subscriptions SET " + strings.Join(cols, ",") + " WHERE tenant_id=? AND topic=?"
args = append(args, id, topic)
if !uid.IsZero() {
q += " AND userid=?"
args = append(args, decodeUID(uid))
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx, q, args...)
return err
}
func (a *adapter) TenantSubsDelete(id t.TenantID, topic string, uid t.Uid) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" || uid.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.SubsDelete(topic, uid)
}
if a.tenantScopedDB {
return a.SubsDelete(topic, uid)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
now := t.TimeNow()
decoded := decodeUID(uid)
res, err := tx.Exec("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic=? AND userid=? AND deletedat IS NULL",
now, now, id, topic, decoded)
if err != nil {
return err
}
if count, _ := res.RowsAffected(); count == 0 {
return t.ErrNotFound
}
if !t.IsChannel(topic) {
if _, err = tx.Exec("DELETE FROM im_dellog WHERE tenant_id=? AND topic=? AND deletedfor=?", id, topic, decoded); err != nil {
return err
}
}
if t.GetTopicCat(topic) == t.TopicCatGrp {
if _, err = tx.Exec("UPDATE im_topics SET subcnt=subcnt-1 WHERE tenant_id=? AND name=?", id, t.ChnToGrp(topic)); err != nil {
return err
}
}
return tx.Commit()
}
func tenantSubsDelForUser(tx *sqlx.Tx, tenantID t.TenantID, decodedUID int64, hard bool) error {
rows, err := tx.Query("SELECT topic FROM im_subscriptions WHERE tenant_id=? AND userid=? AND deletedat IS NULL", tenantID, decodedUID)
if err != nil {
return err
}
var topics []any
for rows.Next() {
var name string
if err = rows.Scan(&name); err != nil {
rows.Close()
return err
}
if t.IsChannel(name) {
name = t.ChnToGrp(name)
}
topics = append(topics, name)
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
if len(topics) > 0 {
q, args, _ := sqlx.In("UPDATE im_topics SET subcnt=subcnt-1 WHERE tenant_id=? AND name IN (?)", tenantID, topics)
if _, err = tx.Exec(tx.Rebind(q), args...); err != nil {
return err
}
}
if hard {
_, err = tx.Exec("DELETE FROM im_subscriptions WHERE tenant_id=? AND userid=?", tenantID, decodedUID)
} else {
now := t.TimeNow()
_, err = tx.Exec("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND userid=? AND deletedat IS NULL",
now, now, tenantID, decodedUID)
}
return err
}
func (a *adapter) TenantFind(id t.TenantID, caller, promoPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
subs, err := tenantAdp.Find(caller, promoPrefix, req, opt, activeOnly)
return stampTenantSubs(id, subs), err
}
if a.tenantScopedDB {
subs, err := a.Find(caller, promoPrefix, req, opt, activeOnly)
return stampTenantSubs(id, subs), err
}
allReq := t.FlattenDoubleSlice(req)
allTags := append(append([]string{}, allReq...), opt...)
if len(allTags) == 0 {
return nil, nil
}
args := []any{id}
stateConstraint := ""
if activeOnly {
args = append(args, t.StateOK)
stateConstraint = "t.state=? AND "
}
index := make(map[string]struct{})
for _, tag := range allTags {
args = append(args, tag)
index[tag] = struct{}{}
}
matcher := "COUNT(*)"
if promoPrefix != "" {
matcher = "SUM(CASE WHEN LOCATE('" + promoPrefix + "', tg.tag)=1 THEN 20 ELSE 1 END)"
}
query := "SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags," + matcher + " AS matches " +
"FROM im_topics AS t JOIN im_topictags AS tg ON t.tenant_id=tg.tenant_id AND t.name=tg.topic " +
"WHERE t.tenant_id=? AND " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allTags)-1) + ") " +
"GROUP BY t.name,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags "
if len(allReq) > 0 {
q, addArgs := common.DisjunctionSql(req, "tg.tag")
query += q
args = append(args, addArgs...)
}
query += "ORDER BY matches DESC, subcnt DESC LIMIT ?"
args = append(args, a.maxResults)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var subs []t.Subscription
for rows.Next() {
var sub t.Subscription
var public, trusted any
var access t.DefaultAccess
var subcnt, ignored int
var setTags t.StringSlice
var isChan bool
if err = rows.Scan(&sub.Topic, &sub.CreatedAt, &sub.UpdatedAt, &isChan, &access, &subcnt,
&public, &trusted, &setTags, &ignored); err != nil {
return nil, err
}
if parsed, err := strconv.ParseInt(sub.Topic, 10, 64); err == nil {
sub.Topic = store.EncodeUid(parsed).UserId()
if sub.Topic == caller {
continue
}
}
if isChan {
sub.Topic = t.GrpToChn(sub.Topic)
}
sub.TenantID = id
sub.SetSubCnt(subcnt)
sub.SetPublic(common.FromJSON(public))
sub.SetTrusted(common.FromJSON(trusted))
sub.SetDefaultAccess(access.Auth, access.Anon)
sub.ModeGiven = t.ModeUnset
sub.ModeWant = t.ModeUnset
sub.Private = common.FilterFoundTags(setTags, index)
subs = append(subs, sub)
}
return subs, rows.Err()
}
func (a *adapter) TenantFindOne(id t.TenantID, tag string) (string, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || tag == "" {
return "", t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.FindOne(tag)
}
if a.tenantScopedDB {
return a.FindOne(tag)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx,
"SELECT tp.name AS topic FROM im_topics AS tp LEFT JOIN im_topictags AS tt ON tp.tenant_id=tt.tenant_id AND tp.name=tt.topic "+
"WHERE tp.tenant_id=? AND tt.tag=? LIMIT 1", id, tag)
if err != nil {
return "", err
}
defer rows.Close()
var found string
if rows.Next() {
if err = rows.Scan(&found); err != nil {
return "", err
}
if parsed, err := strconv.ParseInt(found, 10, 64); err == nil {
found = store.EncodeUid(parsed).UserId()
}
}
return found, rows.Err()
}
func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || msg == nil || msg.TenantID != id {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantMessageSave(id, msg)
}
if a.tenantScopedDB {
return a.MessageSave(msg)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""}
res, err := a.db.ExecContext(ctx,
"INSERT INTO im_messages(tenant_id,createdat,updatedat,seqid,topic,`from`,client_msg_id,version,head,content) VALUES(?,?,?,?,?,?,?,?,?,?)",
id, msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content))
if isDupe(err) {
return t.ErrDuplicate
}
if err == nil {
lastID, _ := res.LastInsertId()
msg.SetUid(t.Uid(lastID))
}
return err
}
func (a *adapter) TenantGroupMessageSave(id t.TenantID, msg *t.Message) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || msg == nil || msg.TenantID != id || msg.Topic == "" || msg.SeqId <= 0 {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageSave(id, msg)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
groupID, err := a.groupIDByTopic(ctx, msg.Topic)
if err != nil {
return err
}
clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""}
contentJSON := common.ToJSON(msg.Content)
res, err := a.db.ExecContext(ctx,
`INSERT INTO im_group_messages
(topic,group_id,seqid,from_user,client_msg_id,msg_type,status,content,head,mentioned_user_ids,mention_all,
file_count,content_size,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
msg.Topic, groupID, msg.SeqId, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageHeadType(msg.Head), "normal",
contentJSON, msg.Head, messageHeadJSON(msg.Head, "mentioned_user_ids"), messageHeadBool(msg.Head, "mention_all"),
0, len(contentJSON), messageVersion(msg.Version), msg.CreatedAt, msg.UpdatedAt)
if err == nil {
lastID, _ := res.LastInsertId()
msg.SetUid(t.Uid(lastID))
}
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMsgID string) (*t.Message, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMessageGetByClientID(id, from, clientMsgID)
}
}
if err := validTenant(id); err != nil || from.IsZero() || clientMsgID == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
err := a.db.QueryRowxContext(ctx,
"SELECT id,createdat,updatedat,deletedat,delid,seqid,topic,`from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content "+
"FROM im_messages WHERE `from`=? AND client_msg_id=?",
decodeUID(from), clientMsgID).StructScan(&row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
msg := finalizeTenantMessage(id, row.Message, row.RecalledBy)
return &msg, nil
}
func (a *adapter) TenantGroupMessageGetByClientID(id t.TenantID, topic string, from t.Uid, clientMsgID string) (*t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageGetByClientID(id, topic, from, clientMsgID)
}
if topic == "" || from.IsZero() || clientMsgID == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
err = a.db.QueryRowxContext(ctx,
"SELECT id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content "+
"FROM im_group_messages WHERE topic=? AND from_user=? AND client_msg_id=?",
topic, decodeUID(from), clientMsgID).StructScan(&row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
return &msg, nil
}
func (a *adapter) TenantMessageGetOne(id t.TenantID, topic string, seq int64) (*t.Message, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMessageGetOne(id, topic, seq)
}
}
if err := validTenant(id); err != nil || topic == "" || seq <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
msg, err := tenantMessageGetOne(a.db, ctx, id, topic, seq, "")
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return msg, err
}
func (a *adapter) TenantGroupMessageGetOne(id t.TenantID, topic string, seq int64) (*t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageGetOne(id, topic, seq)
}
if topic == "" || seq <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
msg, err := tenantGroupMessageGetOne(a.db, ctx, id, topic, seq, "")
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return msg, err
}
func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, recalledAt time.Time) (*t.Message, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMessageRecall(id, topic, seq, actor, expectedVersion, recalledAt)
}
}
if err := validTenant(id); err != nil || topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
msg, err := tenantMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE")
if err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil {
return nil, t.ErrDuplicate
}
_, err = tx.ExecContext(ctx,
"UPDATE im_messages SET updatedat=?,version=version+1,recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE topic=? AND seqid=? AND version=?",
recalledAt, recalledAt, decodeUID(actor), topic, seq, expectedVersion)
if err != nil {
return nil, err
}
if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "recalled"),
"topic", topic, actor, "message.recalled", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.TenantMessageGetOne(id, topic, seq)
}
func (a *adapter) TenantGroupMessageRecall(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, recalledAt time.Time) (*t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageRecall(id, topic, seq, actor, expectedVersion, recalledAt)
}
if topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
msg, err := tenantGroupMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE")
if err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil {
return nil, t.ErrDuplicate
}
_, err = tx.ExecContext(ctx,
"UPDATE im_group_messages SET updated_at=?,version=version+1,status='recalled',recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE topic=? AND seqid=? AND version=?",
recalledAt, recalledAt, decodeUID(actor), topic, seq, expectedVersion)
if err != nil {
return nil, err
}
if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "recalled"),
"topic", topic, actor, "message.recalled", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.TenantGroupMessageGetOne(id, topic, seq)
}
func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, head t.KVMap, content any, reason string, editedAt time.Time) (*t.Message, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMessageEdit(id, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
}
}
if err := validTenant(id); err != nil || topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
msg, err := tenantMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE")
if err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil {
return nil, t.ErrDuplicate
}
if _, err = tx.ExecContext(ctx,
"INSERT INTO im_message_revisions(createdat,updatedat,topic,seqid,version,actor,head,content,reason) VALUES(?,?,?,?,?,?,?,?,?)",
editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), strings.TrimSpace(reason)); err != nil {
return nil, err
}
_, err = tx.ExecContext(ctx,
"UPDATE im_messages SET updatedat=?,version=version+1,head=?,content=? WHERE topic=? AND seqid=? AND version=?",
editedAt, head, common.ToJSON(content), topic, seq, expectedVersion)
if err != nil {
return nil, err
}
if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "edited"),
"topic", topic, actor, "message.edited", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.TenantMessageGetOne(id, topic, seq)
}
func (a *adapter) TenantGroupMessageEdit(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, head t.KVMap, content any, reason string, editedAt time.Time) (*t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageEdit(id, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
}
if topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
msg, err := tenantGroupMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE")
if err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil {
return nil, t.ErrDuplicate
}
if _, err = tx.ExecContext(ctx,
"INSERT INTO im_message_revisions(createdat,updatedat,topic,seqid,version,actor,head,content,reason) VALUES(?,?,?,?,?,?,?,?,?)",
editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), strings.TrimSpace(reason)); err != nil {
return nil, err
}
contentJSON := common.ToJSON(content)
_, err = tx.ExecContext(ctx,
"UPDATE im_group_messages SET updated_at=?,version=version+1,msg_type=?,head=?,content=?,content_size=? WHERE topic=? AND seqid=? AND version=?",
editedAt, messageHeadType(head), head, contentJSON, len(contentJSON), topic, seq, expectedVersion)
if err != nil {
return nil, err
}
if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "edited"),
"topic", topic, actor, "message.edited", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.TenantGroupMessageGetOne(id, topic, seq)
}
func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return nil, t.ErrMalformed
}
if tenantAdp != a {
msgs, err := tenantAdp.MessageGetAll(topic, uid, opts)
return stampTenantMessages(id, msgs), err
}
if a.tenantScopedDB {
msgs, err := a.MessageGetAll(topic, uid, opts)
return stampTenantMessages(id, msgs), err
}
limit := a.maxMessageResults
args := []any{id, topic, id, decodeUID(uid)}
seq := ""
if opts != nil {
seq = "AND m.seqid "
if len(opts.IdRanges) > 0 {
constr, addArgs := common.RangesToSql(opts.IdRanges)
seq += constr
args = append(args, addArgs...)
} else {
seq += "BETWEEN ? AND ?"
if opts.Since > 0 {
args = append(args, opts.Since)
} else {
args = append(args, 0)
}
if opts.Before > 1 {
args = append(args, opts.Before-1)
} else {
args = append(args, 1<<31-1)
}
}
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
}
args = append(args, limit)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx,
"SELECT m.tenant_id,m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+
"FROM im_messages AS m LEFT JOIN im_dellog AS d ON d.tenant_id=m.tenant_id AND d.topic=m.topic "+
"AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE m.tenant_id=? AND m.delid=0 AND m.topic=? "+
seq+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?",
append([]any{decodeUID(uid), id, topic}, args[4:]...)...)
if err != nil {
return nil, err
}
defer rows.Close()
msgs := make([]t.Message, 0, limit)
for rows.Next() {
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, err
}
msgs = append(msgs, finalizeTenantMessage(id, row.Message, row.RecalledBy))
}
return msgs, rows.Err()
}
func (a *adapter) TenantGroupMessageGetAll(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageGetAll(id, topic, uid, opts)
}
limit := a.maxMessageResults
args := []any{store.DecodeUid(uid), topic}
seq := ""
if opts != nil {
seq = "AND m.seqid "
if len(opts.IdRanges) > 0 {
constr, addArgs := common.RangesToSql(opts.IdRanges)
seq += constr
args = append(args, addArgs...)
} else {
seq += "BETWEEN ? AND ?"
if opts.Since > 0 {
args = append(args, opts.Since)
} else {
args = append(args, 0)
}
if opts.Before > 1 {
args = append(args, opts.Before-1)
} else {
args = append(args, 1<<31-1)
}
}
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
}
args = append(args, limit)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx,
"SELECT m.id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+
"FROM im_group_messages AS m LEFT JOIN im_dellog AS d "+
"ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? "+
"WHERE m.status!='deleted' AND m.topic=? "+seq+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?",
args...)
if err != nil {
return nil, err
}
defer rows.Close()
msgs := make([]t.Message, 0, limit)
for rows.Next() {
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, err
}
msgs = append(msgs, normalizeGroupMessage(id, row.Message, row.RecalledBy))
}
return msgs, rows.Err()
}
func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, queryText, messageType, cursor string, limit int) ([]t.Message, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantMessageSearch(id, topic, uid, queryText, messageType, cursor, limit)
}
}
if err := validTenant(id); err != nil || topic == "" || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
if t.GetTopicCat(topic) == t.TopicCatGrp {
return a.tenantGroupMessageSearch(id, topic, uid, queryText, messageType, cursor, limit)
}
where := []string{"m.topic=?", "m.delid=0"}
args := []any{topic}
if cursor != "" {
parsed, err := strconv.Atoi(cursor)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "m.seqid<?")
args = append(args, parsed)
}
queryText = strings.TrimSpace(queryText)
if queryText != "" {
where = append(where, "CAST(m.content AS CHAR) LIKE ?")
args = append(args, "%"+queryText+"%")
}
if messageType != "" && messageType != "all" {
where = append(where, "(JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.type'))=? OR JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.\"x-im-type\"'))=?)")
args = append(args, messageType, messageType)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
queryArgs := append([]any{decodeUID(uid)}, args...)
queryArgs = append(queryArgs, limit+1)
rows, err := a.db.QueryxContext(ctx,
"SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+
"FROM im_messages AS m LEFT JOIN im_dellog AS d ON d.topic=m.topic "+
"AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE "+strings.Join(where, " AND ")+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?",
queryArgs...)
if err != nil {
return nil, "", err
}
defer rows.Close()
msgs := make([]t.Message, 0, limit)
nextCursor := ""
lastSeq := int64(0)
for rows.Next() {
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
msg := finalizeTenantMessage(id, row.Message, row.RecalledBy)
if len(msgs) == limit {
nextCursor = strconv.FormatInt(lastSeq, 10)
continue
}
msgs = append(msgs, msg)
lastSeq = msg.SeqId
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return msgs, nextCursor, nil
}
func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Uid, queryText, messageType, cursor string, limit int) ([]t.Message, string, error) {
where := []string{"m.status!='deleted'", "m.topic=?"}
args := []any{topic}
if cursor != "" {
parsed, err := strconv.Atoi(cursor)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "m.seqid<?")
args = append(args, parsed)
}
queryText = strings.TrimSpace(queryText)
if queryText != "" {
where = append(where, "CAST(m.content AS CHAR) LIKE ?")
args = append(args, "%"+queryText+"%")
}
messageType = strings.TrimSpace(messageType)
if messageType != "" && messageType != "all" {
where = append(where, "m.msg_type=?")
args = append(args, messageType)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
queryArgs := append([]any{store.DecodeUid(uid)}, args...)
queryArgs = append(queryArgs, limit+1)
rows, err := a.db.QueryxContext(ctx,
"SELECT m.id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+
"FROM im_group_messages AS m LEFT JOIN im_dellog AS d "+
"ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? "+
"WHERE "+strings.Join(where, " AND ")+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?",
queryArgs...)
if err != nil {
return nil, "", err
}
defer rows.Close()
msgs := make([]t.Message, 0, limit)
nextCursor := ""
lastSeq := int64(0)
for rows.Next() {
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
if len(msgs) == limit {
nextCursor = strconv.FormatInt(lastSeq, 10)
continue
}
msgs = append(msgs, msg)
lastSeq = msg.SeqId
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return msgs, nextCursor, nil
}
func (a *adapter) TenantCallCreate(id t.TenantID, record *t.CallRecord) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantCallCreate(id, record)
}
}
if err := validTenant(id); err != nil || record == nil || record.TenantID != id || record.CallID == "" ||
record.Topic == "" || record.SeqID <= 0 || record.CallerID.IsZero() || record.CalleeID.IsZero() ||
record.CallType == "" {
return t.ErrMalformed
}
now := t.TimeNow()
if record.StartedAt.IsZero() {
record.StartedAt = now
}
record.CreatedAt = now
record.UpdatedAt = now
if record.Status == "" {
record.Status = t.CallStatusStarted
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx,
`INSERT INTO im_call_records
(call_id,topic,seq_id,caller_id,callee_id,call_type,status,end_reason,duration_ms,started_at,accepted_at,finished_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=VALUES(updated_at)`,
record.CallID, record.Topic, record.SeqID, decodeUID(record.CallerID), decodeUID(record.CalleeID),
record.CallType, record.Status, record.EndReason, record.DurationMS, record.StartedAt, record.AcceptedAt,
record.FinishedAt, record.CreatedAt, record.UpdatedAt)
return err
}
func (a *adapter) TenantCallAccept(id t.TenantID, callID string, seq int64, acceptedAt time.Time) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantCallAccept(id, callID, seq, acceptedAt)
}
}
if err := validTenant(id); err != nil || callID == "" || seq <= 0 || acceptedAt.IsZero() {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx,
`UPDATE im_call_records
SET status=?,accepted_at=?,updated_at=?
WHERE call_id=? AND seq_id=? AND status=?`,
t.CallStatusAccepted, acceptedAt, acceptedAt, callID, seq, t.CallStatusStarted)
return err
}
func (a *adapter) TenantCallFinish(id t.TenantID, callID string, seq int64, status string, durationMS int64, finishedAt time.Time) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantCallFinish(id, callID, seq, status, durationMS, finishedAt)
}
}
if err := validTenant(id); err != nil || callID == "" || seq <= 0 || finishedAt.IsZero() || durationMS < 0 {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx,
`UPDATE im_call_records
SET status=?,end_reason=?,duration_ms=?,finished_at=?,updated_at=?
WHERE call_id=? AND seq_id=?`,
status, status, durationMS, finishedAt, finishedAt, callID, seq)
return err
}
func (a *adapter) TenantCallList(id t.TenantID, uid t.Uid, topic, cursor string, limit int) ([]t.CallRecord, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantCallList(id, uid, topic, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
where := []string{"(caller_id=? OR callee_id=?)"}
args := []any{decodeUID(uid), decodeUID(uid)}
if topic != "" {
where = append(where, "topic=?")
args = append(args, topic)
}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "id<?")
args = append(args, parsed)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx,
`SELECT id,call_id,topic,seq_id,caller_id,callee_id,call_type,status,end_reason,duration_ms,
started_at,accepted_at,finished_at,created_at,updated_at
FROM im_call_records WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`,
args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
records := make([]t.CallRecord, 0, limit)
nextCursor := ""
for rows.Next() {
row := struct {
ID int64
CallID string `db:"call_id"`
Topic string
SeqID int64 `db:"seq_id"`
CallerID int64 `db:"caller_id"`
CalleeID int64 `db:"callee_id"`
CallType string `db:"call_type"`
Status string
EndReason string `db:"end_reason"`
DurationMS int64 `db:"duration_ms"`
StartedAt time.Time `db:"started_at"`
AcceptedAt *time.Time `db:"accepted_at"`
FinishedAt *time.Time `db:"finished_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
if len(records) == limit {
nextCursor = strconv.FormatInt(row.ID, 10)
continue
}
records = append(records, t.CallRecord{
ID: row.ID,
TenantID: id,
CallID: row.CallID,
Topic: row.Topic,
SeqID: row.SeqID,
CallerID: store.EncodeUid(row.CallerID),
CalleeID: store.EncodeUid(row.CalleeID),
CallType: row.CallType,
Status: row.Status,
EndReason: row.EndReason,
DurationMS: row.DurationMS,
StartedAt: row.StartedAt,
AcceptedAt: row.AcceptedAt,
FinishedAt: row.FinishedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
})
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return records, nextCursor, nil
}
func (a *adapter) TenantWorkbenchModules(id t.TenantID, uid t.Uid) ([]t.WorkbenchModule, int, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, 0, err
}
if tenantAdp != a {
return tenantAdp.TenantWorkbenchModules(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, 0, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx,
`SELECT id,code,name,icon_file_id,route,sort_order,unread_key,enabled,permission,created_at,updated_at
FROM im_workbench_apps
WHERE enabled=true
ORDER BY sort_order ASC,id ASC`)
if err != nil {
return nil, 0, err
}
defer rows.Close()
modules := make([]t.WorkbenchModule, 0, 8)
layoutVersion := 0
for rows.Next() {
var module t.WorkbenchModule
if err = rows.StructScan(&module); err != nil {
return nil, 0, err
}
if !isAllowedWorkbenchRoute(module.Route) {
continue
}
if int(module.UpdatedAt.Unix()) > layoutVersion {
layoutVersion = int(module.UpdatedAt.Unix())
}
modules = append(modules, module)
}
if err = rows.Err(); err != nil {
return nil, 0, err
}
if layoutVersion == 0 {
layoutVersion = 1
}
return modules, layoutVersion, nil
}
func (a *adapter) TenantOrganizationDepartments(id t.TenantID, uid t.Uid, parentID, cursor string, limit int) ([]t.OrganizationDepartment, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantOrganizationDepartments(id, uid, parentID, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
parentID = strings.TrimSpace(parentID)
if parentID == "" {
parentID = "root"
}
if limit <= 0 || limit > 100 {
limit = 50
}
where := []string{"d.parent_id=?", "d.state='active'"}
args := []any{parentID}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "d.id>?")
args = append(args, parsed)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, "", err
}
cte, cteArgs := visibility.departmentCTE()
memberCountSQL, memberCountArgs := visibility.memberCountSQL("m")
if visibility.Scope != orgVisibilityAll {
where = append(where, "d.department_id IN (SELECT department_id FROM visible_depts)")
}
queryArgs := append([]any{}, cteArgs...)
queryArgs = append(queryArgs, memberCountArgs...)
queryArgs = append(queryArgs, args...)
childCountSQL := `(SELECT COUNT(*) FROM im_org_departments AS child WHERE child.parent_id=d.department_id AND child.state='active')`
if visibility.Scope != orgVisibilityAll {
childCountSQL = `(SELECT COUNT(*) FROM im_org_departments AS child WHERE child.parent_id=d.department_id AND child.state='active' AND child.department_id IN (SELECT department_id FROM visible_depts))`
}
rows, err := a.db.QueryxContext(ctx, cte+`SELECT d.id,d.department_id,d.name,d.parent_id,d.sort_order,d.version,d.state,d.created_at,d.updated_at,
`+childCountSQL+` AS child_count,
(SELECT COUNT(*) FROM im_org_members AS m WHERE m.department_id=d.department_id AND m.employment_state='active'`+memberCountSQL+`) AS member_count
FROM im_org_departments AS d
WHERE `+strings.Join(where, " AND ")+`
ORDER BY d.sort_order ASC,d.id ASC LIMIT ?`, queryArgs...)
if err != nil {
return nil, "", err
}
defer rows.Close()
departments := make([]t.OrganizationDepartment, 0, limit)
nextCursor := ""
for rows.Next() {
var department t.OrganizationDepartment
if err = rows.StructScan(&department); err != nil {
return nil, "", err
}
if len(departments) == limit {
nextCursor = strconv.FormatInt(department.ID, 10)
continue
}
departments = append(departments, department)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return departments, nextCursor, nil
}
func (a *adapter) TenantOrganizationMembers(id t.TenantID, uid t.Uid, departmentID, cursor string, limit int) ([]t.OrganizationMember, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantOrganizationMembers(id, uid, departmentID, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(departmentID) == "" {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
where := []string{"m.department_id=?", "m.employment_state='active'"}
args := []any{strings.TrimSpace(departmentID)}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "m.id>?")
args = append(args, parsed)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, "", err
}
visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m")
if visibilitySQL != "" {
where = append(where, visibilitySQL)
args = append(args, visibilityArgs...)
}
rows, err := a.db.QueryxContext(ctx, `SELECT m.id,m.department_id,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email,
m.employment_state,m.contactable,m.sort_order,m.version,m.created_at,m.updated_at
FROM im_org_members AS m
WHERE `+strings.Join(where, " AND ")+`
ORDER BY m.sort_order ASC,m.id ASC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
members := make([]t.OrganizationMember, 0, limit)
nextCursor := ""
for rows.Next() {
member, err := scanOrganizationMember(rows)
if err != nil {
return nil, "", err
}
if len(members) == limit {
nextCursor = strconv.FormatInt(member.ID, 10)
continue
}
members = append(members, *member)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return members, nextCursor, nil
}
func (a *adapter) TenantOrganizationMembersAll(id t.TenantID, uid t.Uid, departmentID, queryText, cursor string, limit int) ([]t.OrganizationMember, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantOrganizationMembersAll(id, uid, departmentID, queryText, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
where := []string{"m.employment_state='active'"}
args := []any{}
if strings.TrimSpace(departmentID) != "" {
where = append(where, "m.department_id=?")
args = append(args, strings.TrimSpace(departmentID))
}
if q := strings.TrimSpace(queryText); q != "" {
like := "%" + q + "%"
where = append(where, "(m.display_name LIKE ? OR m.mobile LIKE ? OR m.email LIKE ? OR CAST(m.user_id AS CHAR) LIKE ?)")
args = append(args, like, like, like, like)
}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "m.id>?")
args = append(args, parsed)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, "", err
}
visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m")
if visibilitySQL != "" {
where = append(where, visibilitySQL)
args = append(args, visibilityArgs...)
}
rows, err := a.db.QueryxContext(ctx, `SELECT m.id,m.department_id,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email,
m.employment_state,m.contactable,m.sort_order,m.version,m.created_at,m.updated_at
FROM im_org_members AS m
WHERE `+strings.Join(where, " AND ")+`
ORDER BY m.sort_order ASC,m.id ASC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
members := make([]t.OrganizationMember, 0, limit)
nextCursor := ""
for rows.Next() {
member, err := scanOrganizationMember(rows)
if err != nil {
return nil, "", err
}
if len(members) == limit {
nextCursor = strconv.FormatInt(member.ID, 10)
continue
}
members = append(members, *member)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return members, nextCursor, nil
}
func (a *adapter) TenantOrganizationSearch(id t.TenantID, uid t.Uid, queryText, resultTypes, cursor string, limit int) ([]t.OrganizationSearchItem, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantOrganizationSearch(id, uid, queryText, resultTypes, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(queryText) == "" {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
offset := 0
if cursor != "" {
parsed, err := strconv.Atoi(cursor)
if err != nil || parsed < 0 {
return nil, "", t.ErrMalformed
}
offset = parsed
}
includeDepartments, includeUsers := organizationSearchIncludes(resultTypes)
if !includeDepartments && !includeUsers {
return nil, "", t.ErrMalformed
}
like := "%" + strings.TrimSpace(queryText) + "%"
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, "", err
}
cte, cteArgs := visibility.departmentCTE()
parts := make([]string, 0, 2)
args := make([]any, 0, 10)
args = append(args, cteArgs...)
if includeDepartments {
departmentWhere := "d.state='active' AND d.name LIKE ?"
if visibility.Scope != orgVisibilityAll {
departmentWhere += " AND d.department_id IN (SELECT department_id FROM visible_depts)"
}
parts = append(parts, `SELECT 'department' AS result_type,d.id,d.department_id,d.name,d.parent_id,d.sort_order,d.version,d.state,d.created_at,d.updated_at,
(SELECT COUNT(*) FROM im_org_departments AS child WHERE child.parent_id=d.department_id AND child.state='active') AS child_count,
(SELECT COUNT(*) FROM im_org_members AS m WHERE m.department_id=d.department_id) AS member_count,
0 AS user_id,'' AS display_name,'' AS position,'' AS avatar_file_id,'' AS mobile,'' AS email,'' AS employment_state,0 AS contactable
FROM im_org_departments AS d WHERE `+departmentWhere)
args = append(args, like)
}
if includeUsers {
userWhere := "(m.display_name LIKE ? OR m.mobile LIKE ? OR m.email LIKE ? OR CAST(m.user_id AS CHAR) LIKE ?) AND m.employment_state='active'"
visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m")
if visibilitySQL != "" {
userWhere += " AND " + visibilitySQL
}
parts = append(parts, `SELECT 'user' AS result_type,m.id,'' AS department_id,'' AS name,m.department_id AS parent_id,m.sort_order,m.version,'' AS state,m.created_at,m.updated_at,
0 AS child_count,0 AS member_count,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email,m.employment_state,m.contactable
FROM im_org_members AS m
WHERE `+userWhere)
args = append(args, like, like, like, like)
args = append(args, visibilityArgs...)
}
args = append(args, offset, limit+1)
rows, err := a.db.QueryxContext(ctx, cte+`SELECT * FROM (`+strings.Join(parts, " UNION ALL ")+`) AS results
ORDER BY result_type ASC,id ASC LIMIT ?,?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
items := make([]t.OrganizationSearchItem, 0, limit)
nextCursor := ""
for rows.Next() {
item, err := scanOrganizationSearchItem(rows)
if err != nil {
return nil, "", err
}
if len(items) == limit {
nextCursor = strconv.Itoa(offset + limit)
continue
}
items = append(items, *item)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return items, nextCursor, nil
}
const (
orgVisibilityAll = "all"
orgVisibilityFriends = "friends"
orgVisibilitySelf = "self"
)
type organizationVisibility struct {
Scope string
UserID int64
}
func (a *adapter) organizationVisibility(ctx context.Context, uid t.Uid) (organizationVisibility, error) {
userID := decodeUID(uid)
visibility := organizationVisibility{Scope: orgVisibilitySelf, UserID: userID}
if userID <= 0 {
return visibility, t.ErrMalformed
}
var override string
err := a.db.QueryRowxContext(ctx, `SELECT visibility_scope FROM im_org_visibility_overrides
WHERE user_id=? AND state='active' LIMIT 1`, userID).Scan(&override)
if err != nil && err != sql.ErrNoRows {
return visibility, err
}
if err == nil {
switch strings.TrimSpace(override) {
case orgVisibilityAll, orgVisibilityFriends, orgVisibilitySelf:
visibility.Scope = strings.TrimSpace(override)
return visibility, nil
default:
return visibility, t.ErrMalformed
}
}
var phone sql.NullString
err = a.db.QueryRowxContext(ctx, `SELECT phone FROM im_users WHERE id=? AND state<>? LIMIT 1`, userID, t.StateDeleted).Scan(&phone)
if err == sql.ErrNoRows {
return visibility, t.ErrNotFound
}
if err != nil {
return visibility, err
}
var inviteUsed int
err = a.db.QueryRowxContext(ctx, `SELECT EXISTS(SELECT 1 FROM im_registration_invite_uses WHERE user_id=?)`, userID).Scan(&inviteUsed)
if err != nil {
return visibility, err
}
if inviteUsed > 0 {
visibility.Scope = orgVisibilitySelf
return visibility, nil
}
if phone.Valid && strings.TrimSpace(phone.String) != "" {
visibility.Scope = orgVisibilityFriends
}
return visibility, nil
}
func (v organizationVisibility) memberWhereSQL(alias string) (string, []any) {
alias = strings.TrimSpace(alias)
if alias == "" {
alias = "m"
}
switch v.Scope {
case orgVisibilityAll:
return "", nil
case orgVisibilityFriends:
return "(" + alias + ".user_id=? OR EXISTS (SELECT 1 FROM im_friendships AS fs WHERE fs.user_id=? AND fs.peer_id=" + alias + ".user_id AND fs.status=?))",
[]any{v.UserID, v.UserID, t.RelationshipFriend}
default:
return alias + ".user_id=?", []any{v.UserID}
}
}
func (v organizationVisibility) memberCountSQL(alias string) (string, []any) {
where, args := v.memberWhereSQL(alias)
if where == "" {
return "", nil
}
return " AND " + where, args
}
func (v organizationVisibility) departmentCTE() (string, []any) {
if v.Scope == orgVisibilityAll {
return "", nil
}
memberWhere, args := v.memberWhereSQL("m")
return `WITH RECURSIVE visible_member_depts AS (
SELECT DISTINCT m.department_id FROM im_org_members AS m
WHERE m.employment_state='active' AND ` + memberWhere + `
), visible_depts AS (
SELECT d.department_id,d.parent_id FROM im_org_departments AS d
JOIN visible_member_depts AS vmd ON vmd.department_id=d.department_id
WHERE d.state='active'
UNION DISTINCT
SELECT parent.department_id,parent.parent_id FROM im_org_departments AS parent
JOIN visible_depts AS child ON child.parent_id=parent.department_id
WHERE parent.state='active'
) `, args
}
func organizationSearchIncludes(resultTypes string) (bool, bool) {
if strings.TrimSpace(resultTypes) == "" {
return true, true
}
includeDepartments, includeUsers := false, false
for _, item := range strings.Split(resultTypes, ",") {
switch strings.TrimSpace(item) {
case "department":
includeDepartments = true
case "user":
includeUsers = true
}
}
return includeDepartments, includeUsers
}
func scanOrganizationMember(rows *sqlx.Rows) (*t.OrganizationMember, error) {
row := struct {
ID int64
DepartmentID string `db:"department_id"`
UserID int64 `db:"user_id"`
DisplayName string `db:"display_name"`
Position string
AvatarFileID string `db:"avatar_file_id"`
Mobile string
Email string
EmploymentState string `db:"employment_state"`
Contactable bool
SortOrder int `db:"sort_order"`
Version int
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := rows.StructScan(&row); err != nil {
return nil, err
}
return &t.OrganizationMember{
ID: row.ID,
DepartmentID: row.DepartmentID,
UserID: store.EncodeUid(row.UserID),
DisplayName: row.DisplayName,
Position: row.Position,
AvatarFileID: row.AvatarFileID,
Mobile: row.Mobile,
Email: row.Email,
Status: row.EmploymentState,
Contactable: row.Contactable && row.EmploymentState == "active",
SortOrder: row.SortOrder,
Version: row.Version,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}, nil
}
func scanOrganizationSearchItem(rows *sqlx.Rows) (*t.OrganizationSearchItem, error) {
row := struct {
ResultType string `db:"result_type"`
ID int64
DepartmentID string `db:"department_id"`
Name string
ParentID string `db:"parent_id"`
SortOrder int `db:"sort_order"`
Version int
State string
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ChildCount int `db:"child_count"`
MemberCount int `db:"member_count"`
UserID int64 `db:"user_id"`
DisplayName string `db:"display_name"`
Position string
AvatarFileID string `db:"avatar_file_id"`
Mobile string
Email string
EmploymentState string `db:"employment_state"`
Contactable bool
}{}
if err := rows.StructScan(&row); err != nil {
return nil, err
}
item := &t.OrganizationSearchItem{Type: row.ResultType}
if row.ResultType == "department" {
item.Department = &t.OrganizationDepartment{
ID: row.ID,
DepartmentID: row.DepartmentID,
Name: row.Name,
ParentID: row.ParentID,
SortOrder: row.SortOrder,
ChildCount: row.ChildCount,
MemberCount: row.MemberCount,
Version: row.Version,
State: row.State,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
return item, nil
}
item.Member = &t.OrganizationMember{
ID: row.ID,
DepartmentID: row.ParentID,
UserID: store.EncodeUid(row.UserID),
DisplayName: row.DisplayName,
Position: row.Position,
AvatarFileID: row.AvatarFileID,
Mobile: row.Mobile,
Email: row.Email,
Status: row.EmploymentState,
Contactable: row.Contactable && row.EmploymentState == "active",
SortOrder: row.SortOrder,
Version: row.Version,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
return item, nil
}
func (a *adapter) TenantMeetingCreate(id t.TenantID, uid t.Uid, meeting *t.Meeting, attendeeIDs []t.Uid) (*t.Meeting, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingCreate(id, uid, meeting, attendeeIDs)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || meeting == nil || strings.TrimSpace(meeting.MeetingID) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if meeting.IdempotencyKey != "" {
existing, err := a.meetingByIdempotency(ctx, uid, meeting.IdempotencyKey)
if err == nil {
return existing, nil
}
if err != t.ErrNotFound {
return nil, err
}
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if tx != nil {
tx.Rollback()
}
}()
reminderJSON, err := json.Marshal(meeting.ReminderMinutes)
if err != nil {
return nil, err
}
var idempotencyKey any
if meeting.IdempotencyKey != "" {
idempotencyKey = meeting.IdempotencyKey
}
recorderID := meeting.RecorderID
if recorderID.IsZero() {
recorderID = uid
}
_, err = tx.ExecContext(ctx, `INSERT INTO im_meetings(meeting_id,organizer_id,recorder_user_id,title,start_at,end_at,location,state,reminder_minutes,idempotency_key,version)
VALUES(?,?,?,?,?,?,?,?,?,?,1)`, meeting.MeetingID, decodeUID(uid), decodeUID(recorderID), strings.TrimSpace(meeting.Title), meeting.StartAt.UTC(), meeting.EndAt.UTC(), strings.TrimSpace(meeting.Location),
t.MeetingStateScheduled, string(reminderJSON), idempotencyKey)
if err != nil {
if isDupe(err) && meeting.IdempotencyKey != "" {
tx.Rollback()
tx = nil
return a.meetingByIdempotency(ctx, uid, meeting.IdempotencyKey)
}
return nil, err
}
participants := uniqueMeetingParticipants(uid, append(attendeeIDs, recorderID))
now := time.Now().UTC().Round(time.Millisecond)
for _, participant := range participants {
response := t.MeetingResponsePending
var responseAt any
if participant == uid {
response = t.MeetingResponseAccepted
responseAt = now
}
if _, err = tx.ExecContext(ctx, `INSERT INTO im_meeting_participants(meeting_id,user_id,response,response_at)
VALUES(?,?,?,?)`, meeting.MeetingID, decodeUID(participant), response, responseAt); err != nil {
return nil, err
}
}
if err = insertMeetingEventsTx(tx, principalMeetingEvent{
Actor: uid, MeetingID: meeting.MeetingID, Title: strings.TrimSpace(meeting.Title),
State: t.MeetingStateScheduled, Version: 1, EventType: "meeting.created",
}, participants); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
tx = nil
return a.TenantMeetingGet(id, uid, meeting.MeetingID)
}
func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, limit int) ([]t.Meeting, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingList(id, uid, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 20
}
where := []string{`(m.organizer_id=? OR m.recorder_user_id=? OR EXISTS (
SELECT 1 FROM im_meeting_participants AS p WHERE p.meeting_id=m.meeting_id AND p.user_id=?
))`}
args := []any{decodeUID(uid), decodeUID(uid), decodeUID(uid)}
if strings.TrimSpace(cursor) != "" {
parsed, err := strconv.ParseInt(strings.TrimSpace(cursor), 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "m.id<?")
args = append(args, parsed)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
args = append(args, limit+1)
rows, err := a.db.QueryxContext(ctx, `SELECT m.* FROM im_meetings AS m
WHERE `+strings.Join(where, " AND ")+`
ORDER BY m.start_at DESC,m.id DESC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
meetings := make([]t.Meeting, 0, limit)
nextCursor := ""
for rows.Next() {
row := struct {
ID int64
MeetingID string `db:"meeting_id"`
OrganizerID int64 `db:"organizer_id"`
RecorderID int64 `db:"recorder_user_id"`
Title string
StartAt time.Time `db:"start_at"`
EndAt time.Time `db:"end_at"`
Location string
State string
ReminderMinutes []byte `db:"reminder_minutes"`
IdempotencyKey sql.NullString `db:"idempotency_key"`
Version int
CanceledAt *time.Time `db:"canceled_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
meeting := t.Meeting{
ID: row.ID,
MeetingID: row.MeetingID,
OrganizerID: store.EncodeUid(row.OrganizerID),
RecorderID: store.EncodeUid(row.RecorderID),
Title: row.Title,
StartAt: row.StartAt,
EndAt: row.EndAt,
Location: row.Location,
State: row.State,
Version: row.Version,
CanceledAt: row.CanceledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if row.IdempotencyKey.Valid {
meeting.IdempotencyKey = row.IdempotencyKey.String
}
if meeting.RecorderID.IsZero() {
meeting.RecorderID = meeting.OrganizerID
}
if len(row.ReminderMinutes) > 0 {
_ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes)
}
if len(meetings) == limit {
nextCursor = strconv.FormatInt(row.ID, 10)
continue
}
participants, err := a.meetingParticipants(ctx, row.MeetingID)
if err != nil {
return nil, "", err
}
meeting.Participants = participants
meetings = append(meetings, meeting)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return meetings, nextCursor, nil
}
func (a *adapter) TenantMeetingGet(id t.TenantID, uid t.Uid, meetingID string) (*t.Meeting, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingGet(id, uid, meetingID)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid)
if err != nil {
return nil, err
}
participants, err := a.meetingParticipants(ctx, meeting.MeetingID)
if err != nil {
return nil, err
}
meeting.Participants = participants
return meeting, nil
}
func (a *adapter) TenantMeetingUpdate(id t.TenantID, uid t.Uid, meetingID string, patch t.MeetingPatch, _ string) (*t.Meeting, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingUpdate(id, uid, meetingID, patch, "")
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" || patch.Version <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid)
if err != nil {
return nil, err
}
if meeting.OrganizerID != uid {
return nil, t.ErrPermissionDenied
}
if meeting.State != t.MeetingStateScheduled {
return nil, t.ErrPermissionDenied
}
if meeting.Version != patch.Version {
return nil, t.ErrInvalidResponse
}
effectiveStart := meeting.StartAt
effectiveEnd := meeting.EndAt
if patch.StartAt != nil {
effectiveStart = *patch.StartAt
}
if patch.EndAt != nil {
effectiveEnd = *patch.EndAt
}
if !effectiveEnd.After(effectiveStart) {
return nil, t.ErrMalformed
}
sets := []string{"version=version+1", "updated_at=CURRENT_TIMESTAMP(3)"}
args := make([]any, 0, 8)
if patch.Title != nil {
sets = append(sets, "title=?")
args = append(args, strings.TrimSpace(*patch.Title))
}
if patch.StartAt != nil {
sets = append(sets, "start_at=?")
args = append(args, patch.StartAt.UTC())
}
if patch.EndAt != nil {
sets = append(sets, "end_at=?")
args = append(args, patch.EndAt.UTC())
}
if patch.Location != nil {
sets = append(sets, "location=?")
args = append(args, strings.TrimSpace(*patch.Location))
}
if patch.RecorderID != nil {
if patch.RecorderID.IsZero() {
return nil, t.ErrMalformed
}
sets = append(sets, "recorder_user_id=?")
args = append(args, decodeUID(*patch.RecorderID))
}
if patch.ReminderMinutes != nil {
encoded, err := json.Marshal(patch.ReminderMinutes)
if err != nil {
return nil, err
}
sets = append(sets, "reminder_minutes=?")
args = append(args, string(encoded))
}
args = append(args, meeting.MeetingID, decodeUID(uid), patch.Version)
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if tx != nil {
tx.Rollback()
}
}()
res, err := tx.ExecContext(ctx, `UPDATE im_meetings SET `+strings.Join(sets, ",")+` WHERE meeting_id=? AND organizer_id=? AND version=?`, args...)
if err != nil {
return nil, err
}
affected, err := res.RowsAffected()
if err != nil {
return nil, err
}
if affected == 0 {
return nil, t.ErrInvalidResponse
}
if patch.RecorderID != nil {
if _, err = tx.ExecContext(ctx, `INSERT IGNORE INTO im_meeting_participants(meeting_id,user_id,response,response_at)
VALUES(?,?,?,?)`, meeting.MeetingID, decodeUID(*patch.RecorderID), t.MeetingResponsePending, nil); err != nil {
return nil, err
}
}
participantIDs, err := meetingParticipantIDsTx(ctx, tx, meeting.MeetingID, "")
if err != nil {
return nil, err
}
if err = insertMeetingEventsTx(tx, principalMeetingEvent{
Actor: uid, MeetingID: meeting.MeetingID, Title: effectiveMeetingTitle(meeting.Title, patch.Title),
State: meeting.State, Version: meeting.Version + 1, EventType: "meeting.updated",
}, participantIDs); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
tx = nil
return a.TenantMeetingGet(id, uid, meeting.MeetingID)
}
func (a *adapter) TenantMeetingCancel(id t.TenantID, uid t.Uid, meetingID, _ string) (*t.Meeting, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingCancel(id, uid, meetingID, "")
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid)
if err != nil {
return nil, err
}
if meeting.OrganizerID != uid {
return nil, t.ErrPermissionDenied
}
if meeting.State == t.MeetingStateCanceled {
return a.TenantMeetingGet(id, uid, meeting.MeetingID)
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if tx != nil {
tx.Rollback()
}
}()
_, err = tx.ExecContext(ctx, `UPDATE im_meetings SET state=?,version=version+1,canceled_at=?,updated_at=CURRENT_TIMESTAMP(3)
WHERE meeting_id=? AND organizer_id=?`, t.MeetingStateCanceled, time.Now().UTC().Round(time.Millisecond), meeting.MeetingID, decodeUID(uid))
if err != nil {
return nil, err
}
participantIDs, err := meetingParticipantIDsTx(ctx, tx, meeting.MeetingID, "")
if err != nil {
return nil, err
}
if err = insertMeetingEventsTx(tx, principalMeetingEvent{
Actor: uid, MeetingID: meeting.MeetingID, Title: meeting.Title, State: t.MeetingStateCanceled,
Version: meeting.Version + 1, EventType: "meeting.canceled",
}, participantIDs); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
tx = nil
return a.TenantMeetingGet(id, uid, meeting.MeetingID)
}
func (a *adapter) TenantMeetingRemind(id t.TenantID, uid t.Uid, meetingID, _ string) ([]t.MeetingParticipant, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingRemind(id, uid, meetingID, "")
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid)
if err != nil {
return nil, err
}
if meeting.OrganizerID != uid {
return nil, t.ErrPermissionDenied
}
participants, err := a.meetingParticipantsByResponse(ctx, meeting.MeetingID, t.MeetingResponsePending)
if err != nil {
return nil, err
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if tx != nil {
tx.Rollback()
}
}()
participantIDs := make([]t.Uid, 0, len(participants))
for _, participant := range participants {
participantIDs = append(participantIDs, participant.UserID)
}
if err = insertMeetingEventsTx(tx, principalMeetingEvent{
Actor: uid, MeetingID: meeting.MeetingID, Title: meeting.Title, State: meeting.State,
Version: meeting.Version, EventType: "meeting.reminded",
}, participantIDs); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
tx = nil
return participants, nil
}
func (a *adapter) TenantMeetingRespond(id t.TenantID, uid t.Uid, meetingID, response string, version int) (*t.Meeting, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingRespond(id, uid, meetingID, response, version)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid)
if err != nil {
return nil, err
}
if meeting.State != t.MeetingStateScheduled || time.Now().UTC().After(meeting.EndAt) {
return nil, t.ErrPermissionDenied
}
if meeting.Version != version {
return nil, t.ErrInvalidResponse
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if tx != nil {
tx.Rollback()
}
}()
res, err := tx.ExecContext(ctx, `UPDATE im_meeting_participants SET response=?,response_at=?,updated_at=CURRENT_TIMESTAMP(3)
WHERE meeting_id=? AND user_id=?`, response, time.Now().UTC().Round(time.Millisecond), meeting.MeetingID, decodeUID(uid))
if err != nil {
return nil, err
}
affected, err := res.RowsAffected()
if err != nil {
return nil, err
}
if affected == 0 {
return nil, t.ErrPermissionDenied
}
res, err = tx.ExecContext(ctx, `UPDATE im_meetings SET version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE meeting_id=? AND version=?`, meeting.MeetingID, version)
if err != nil {
return nil, err
}
if affected, err = res.RowsAffected(); err != nil {
return nil, err
}
if affected == 0 {
return nil, t.ErrInvalidResponse
}
participantIDs, err := meetingParticipantIDsTx(ctx, tx, meeting.MeetingID, "")
if err != nil {
return nil, err
}
if err = insertMeetingEventsTx(tx, principalMeetingEvent{
Actor: uid, MeetingID: meeting.MeetingID, Title: meeting.Title, State: meeting.State,
Version: meeting.Version + 1, EventType: "meeting.response.updated",
}, participantIDs); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
tx = nil
return a.TenantMeetingGet(id, uid, meeting.MeetingID)
}
type principalMeetingEvent struct {
Actor t.Uid
MeetingID string
Title string
State string
Version int
EventType string
}
func effectiveMeetingTitle(current string, patch *string) string {
if patch == nil {
return current
}
return strings.TrimSpace(*patch)
}
func meetingParticipantIDsTx(ctx context.Context, tx *sqlx.Tx, meetingID, response string) ([]t.Uid, error) {
query := `SELECT user_id FROM im_meeting_participants WHERE meeting_id=?`
args := []any{meetingID}
if response != "" {
query += ` AND response=?`
args = append(args, response)
}
rows, err := tx.QueryxContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
participants := make([]t.Uid, 0, 8)
for rows.Next() {
var userID int64
if err = rows.Scan(&userID); err != nil {
return nil, err
}
participants = append(participants, store.EncodeUid(userID))
}
return participants, rows.Err()
}
func insertMeetingEventsTx(tx *sqlx.Tx, event principalMeetingEvent, participants []t.Uid) error {
for _, participant := range participants {
if participant.IsZero() {
continue
}
eventID := t.StableEventID("meeting", event.MeetingID, event.Version, event.EventType, participant.UserId())
if participant != event.Actor {
if err := insertSystemNotificationTx(tx, participant, t.StableEventID(eventID, "notification"),
event.EventType, "会议通知", event.Title, t.KVMap{"meeting_id": event.MeetingID}); err != nil {
return err
}
}
if err := insertBusinessEventTx(tx, eventID, "user", participant.UserId(), event.Actor, event.EventType,
map[string]any{"meeting_id": event.MeetingID, "version": event.Version, "state": event.State}); err != nil {
return err
}
}
return nil
}
func (a *adapter) meetingByIdempotency(ctx context.Context, uid t.Uid, idempotencyKey string) (*t.Meeting, error) {
row := struct {
MeetingID string `db:"meeting_id"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT meeting_id FROM im_meetings WHERE organizer_id=? AND idempotency_key=?`, decodeUID(uid), idempotencyKey); err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
return a.meetingByID(ctx, row.MeetingID, uid)
}
func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Uid) (*t.Meeting, error) {
row := struct {
ID int64
MeetingID string `db:"meeting_id"`
OrganizerID int64 `db:"organizer_id"`
RecorderID int64 `db:"recorder_user_id"`
Title string
StartAt time.Time `db:"start_at"`
EndAt time.Time `db:"end_at"`
Location string
State string
ReminderMinutes []byte `db:"reminder_minutes"`
IdempotencyKey sql.NullString `db:"idempotency_key"`
Version int
CanceledAt *time.Time `db:"canceled_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT m.* FROM im_meetings AS m
WHERE m.meeting_id=? AND (m.organizer_id=? OR m.recorder_user_id=? OR EXISTS (
SELECT 1 FROM im_meeting_participants AS p WHERE p.meeting_id=m.meeting_id AND p.user_id=?
))`, meetingID, decodeUID(viewer), decodeUID(viewer), decodeUID(viewer)); err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
meeting := &t.Meeting{
ID: row.ID,
MeetingID: row.MeetingID,
OrganizerID: store.EncodeUid(row.OrganizerID),
RecorderID: store.EncodeUid(row.RecorderID),
Title: row.Title,
StartAt: row.StartAt,
EndAt: row.EndAt,
Location: row.Location,
State: row.State,
Version: row.Version,
CanceledAt: row.CanceledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if row.IdempotencyKey.Valid {
meeting.IdempotencyKey = row.IdempotencyKey.String
}
if meeting.RecorderID.IsZero() {
meeting.RecorderID = meeting.OrganizerID
}
if len(row.ReminderMinutes) > 0 {
_ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes)
}
return meeting, nil
}
func (a *adapter) meetingParticipants(ctx context.Context, meetingID string) ([]t.MeetingParticipant, error) {
return a.meetingParticipantsByQuery(ctx, `SELECT * FROM im_meeting_participants WHERE meeting_id=? ORDER BY id ASC`, meetingID)
}
func (a *adapter) meetingParticipantsByResponse(ctx context.Context, meetingID, response string) ([]t.MeetingParticipant, error) {
return a.meetingParticipantsByQuery(ctx, `SELECT * FROM im_meeting_participants WHERE meeting_id=? AND response=? ORDER BY id ASC`, meetingID, response)
}
func (a *adapter) meetingParticipantsByQuery(ctx context.Context, query string, args ...any) ([]t.MeetingParticipant, error) {
rows, err := a.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
participants := make([]t.MeetingParticipant, 0, 8)
for rows.Next() {
row := struct {
ID int64
MeetingID string `db:"meeting_id"`
UserID int64 `db:"user_id"`
Response string
ResponseAt *time.Time `db:"response_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, err
}
participants = append(participants, t.MeetingParticipant{
ID: row.ID,
MeetingID: row.MeetingID,
UserID: store.EncodeUid(row.UserID),
Response: row.Response,
ResponseAt: row.ResponseAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
})
}
if err = rows.Err(); err != nil {
return nil, err
}
return participants, nil
}
func (a *adapter) TenantMeetingNoteList(id t.TenantID, uid t.Uid, query, cursor string, limit int) ([]t.Meeting, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingNoteList(id, uid, query, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 20
}
where := []string{`m.state<>?`, `m.end_at<=?`, `(m.organizer_id=? OR m.recorder_user_id=? OR EXISTS (
SELECT 1 FROM im_meeting_participants AS p WHERE p.meeting_id=m.meeting_id AND p.user_id=?
))`}
args := []any{t.MeetingStateCanceled, time.Now().UTC(), decodeUID(uid), decodeUID(uid), decodeUID(uid)}
if q := strings.TrimSpace(query); q != "" {
where = append(where, "(m.title LIKE ? OR m.location LIKE ?)")
like := "%" + q + "%"
args = append(args, like, like)
}
if strings.TrimSpace(cursor) != "" {
parsed, err := strconv.ParseInt(strings.TrimSpace(cursor), 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "m.id<?")
args = append(args, parsed)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
args = append(args, limit+1)
rows, err := a.db.QueryxContext(ctx, `SELECT m.* FROM im_meetings AS m
WHERE `+strings.Join(where, " AND ")+`
ORDER BY m.end_at DESC,m.id DESC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
meetings := make([]t.Meeting, 0, limit)
nextCursor := ""
for rows.Next() {
meeting, rowID, err := scanMeetingRow(rows)
if err != nil {
return nil, "", err
}
if len(meetings) == limit {
nextCursor = strconv.FormatInt(rowID, 10)
continue
}
participants, err := a.meetingParticipants(ctx, meeting.MeetingID)
if err != nil {
return nil, "", err
}
meeting.Participants = participants
meeting.State = t.MeetingStateEnded
meetings = append(meetings, *meeting)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return meetings, nextCursor, nil
}
func (a *adapter) TenantMeetingNoteFiles(id t.TenantID, uid t.Uid, meetingID string) ([]t.MeetingNoteFile, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingNoteFiles(id, uid, meetingID)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if _, err := a.meetingNoteVisible(ctx, uid, strings.TrimSpace(meetingID)); err != nil {
return nil, err
}
return a.meetingNoteFiles(ctx, strings.TrimSpace(meetingID))
}
func (a *adapter) TenantMeetingNoteFileCreate(id t.TenantID, uid t.Uid, meetingID, noteFileID, fileID, idempotencyKey string) (*t.MeetingNoteFile, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingNoteFileCreate(id, uid, meetingID, noteFileID, fileID, idempotencyKey)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" || strings.TrimSpace(noteFileID) == "" || strings.TrimSpace(fileID) == "" || strings.TrimSpace(idempotencyKey) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
meeting, err := a.meetingNoteWritable(ctx, uid, strings.TrimSpace(meetingID))
if err != nil {
return nil, err
}
file, err := a.TenantFileGet(id, strings.TrimSpace(fileID))
if err != nil {
return nil, err
}
if file == nil || file.Status != t.UploadCompleted {
return nil, t.ErrNotFound
}
_, err = a.db.ExecContext(ctx, `INSERT INTO im_meeting_note_files(note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,idempotency_key,version)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1)`, strings.TrimSpace(noteFileID), meeting.MeetingID, strings.TrimSpace(fileID), decodeUID(uid), strings.TrimSpace(fileID),
file.MimeType, file.Size, file.ETag, t.MeetingNoteFileStatusActive, "ready", "native", strings.TrimSpace(idempotencyKey))
if err != nil {
if isDupe(err) {
return a.meetingNoteFileByIdempotency(ctx, meeting.MeetingID, uid, strings.TrimSpace(idempotencyKey))
}
return nil, err
}
return a.meetingNoteFileByID(ctx, meeting.MeetingID, strings.TrimSpace(noteFileID))
}
func (a *adapter) TenantMeetingNoteFileReplace(id t.TenantID, uid t.Uid, meetingID, noteFileID, fileID, idempotencyKey string, version int) (*t.MeetingNoteFile, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingNoteFileReplace(id, uid, meetingID, noteFileID, fileID, idempotencyKey, version)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" || strings.TrimSpace(noteFileID) == "" || strings.TrimSpace(fileID) == "" || strings.TrimSpace(idempotencyKey) == "" || version <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
meeting, err := a.meetingNoteWritable(ctx, uid, strings.TrimSpace(meetingID))
if err != nil {
return nil, err
}
file, err := a.TenantFileGet(id, strings.TrimSpace(fileID))
if err != nil {
return nil, err
}
if file == nil || file.Status != t.UploadCompleted {
return nil, t.ErrNotFound
}
res, err := a.db.ExecContext(ctx, `UPDATE im_meeting_note_files
SET file_id=?,file_name=?,mime_type=?,file_size=?,file_etag=?,idempotency_key=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE meeting_id=? AND note_file_id=? AND uploader_id=? AND status=? AND version=?`,
strings.TrimSpace(fileID), strings.TrimSpace(fileID), file.MimeType, file.Size, file.ETag, strings.TrimSpace(idempotencyKey),
meeting.MeetingID, strings.TrimSpace(noteFileID), decodeUID(uid), t.MeetingNoteFileStatusActive, version)
if err != nil {
return nil, err
}
affected, err := res.RowsAffected()
if err != nil {
return nil, err
}
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.meetingNoteFileByID(ctx, meeting.MeetingID, strings.TrimSpace(noteFileID))
}
func (a *adapter) TenantMeetingNoteFileDelete(id t.TenantID, uid t.Uid, meetingID, noteFileID, _ string, version int) (*t.MeetingNoteFile, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingNoteFileDelete(id, uid, meetingID, noteFileID, "", version)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" || strings.TrimSpace(noteFileID) == "" || version <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
meeting, err := a.meetingNoteWritable(ctx, uid, strings.TrimSpace(meetingID))
if err != nil {
return nil, err
}
item, err := a.meetingNoteFileByID(ctx, meeting.MeetingID, strings.TrimSpace(noteFileID))
if err != nil {
return nil, err
}
res, err := a.db.ExecContext(ctx, `UPDATE im_meeting_note_files
SET status=?,deleted_by=?,deleted_at=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE meeting_id=? AND note_file_id=? AND uploader_id=? AND status=? AND version=?`,
t.MeetingNoteFileStatusDeleted, decodeUID(uid), time.Now().UTC().Round(time.Millisecond),
meeting.MeetingID, strings.TrimSpace(noteFileID), decodeUID(uid), t.MeetingNoteFileStatusActive, version)
if err != nil {
return nil, err
}
affected, err := res.RowsAffected()
if err != nil {
return nil, err
}
if affected == 0 {
return nil, t.ErrInvalidResponse
}
deletedAt := time.Now().UTC().Round(time.Millisecond)
item.Status = t.MeetingNoteFileStatusDeleted
item.DeletedBy = uid
item.DeletedAt = &deletedAt
item.Version++
return item, nil
}
func scanMeetingRow(scanner interface{ StructScan(any) error }) (*t.Meeting, int64, error) {
row := struct {
ID int64
MeetingID string `db:"meeting_id"`
OrganizerID int64 `db:"organizer_id"`
RecorderID int64 `db:"recorder_user_id"`
Title string
StartAt time.Time `db:"start_at"`
EndAt time.Time `db:"end_at"`
Location string
State string
ReminderMinutes []byte `db:"reminder_minutes"`
IdempotencyKey sql.NullString `db:"idempotency_key"`
Version int
CanceledAt *time.Time `db:"canceled_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := scanner.StructScan(&row); err != nil {
return nil, 0, err
}
meeting := &t.Meeting{
ID: row.ID,
MeetingID: row.MeetingID,
OrganizerID: store.EncodeUid(row.OrganizerID),
RecorderID: store.EncodeUid(row.RecorderID),
Title: row.Title,
StartAt: row.StartAt,
EndAt: row.EndAt,
Location: row.Location,
State: row.State,
Version: row.Version,
CanceledAt: row.CanceledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if meeting.RecorderID.IsZero() {
meeting.RecorderID = meeting.OrganizerID
}
if row.IdempotencyKey.Valid {
meeting.IdempotencyKey = row.IdempotencyKey.String
}
if len(row.ReminderMinutes) > 0 {
_ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes)
}
return meeting, row.ID, nil
}
func (a *adapter) meetingNoteVisible(ctx context.Context, uid t.Uid, meetingID string) (*t.Meeting, error) {
meeting, err := a.meetingByID(ctx, meetingID, uid)
if err != nil {
return nil, err
}
if meeting.State == t.MeetingStateCanceled || time.Now().UTC().Before(meeting.EndAt) {
return nil, t.ErrPermissionDenied
}
participants, err := a.meetingParticipants(ctx, meeting.MeetingID)
if err != nil {
return nil, err
}
meeting.Participants = participants
meeting.State = t.MeetingStateEnded
return meeting, nil
}
func (a *adapter) meetingNoteWritable(ctx context.Context, uid t.Uid, meetingID string) (*t.Meeting, error) {
meeting, err := a.meetingNoteVisible(ctx, uid, meetingID)
if err != nil {
return nil, err
}
if meeting.RecorderID != uid {
return nil, t.ErrPermissionDenied
}
return meeting, nil
}
func (a *adapter) meetingNoteFiles(ctx context.Context, meetingID string) ([]t.MeetingNoteFile, error) {
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_meeting_note_files
WHERE meeting_id=? AND status=? ORDER BY id DESC`, meetingID, t.MeetingNoteFileStatusActive)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]t.MeetingNoteFile, 0, 4)
for rows.Next() {
item, err := scanMeetingNoteFileRow(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (a *adapter) meetingNoteFileByID(ctx context.Context, meetingID, noteFileID string) (*t.MeetingNoteFile, error) {
row := a.db.QueryRowxContext(ctx, `SELECT * FROM im_meeting_note_files WHERE meeting_id=? AND note_file_id=? LIMIT 1`, meetingID, noteFileID)
item, err := scanMeetingNoteFileRow(row)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return &item, nil
}
func (a *adapter) meetingNoteFileByIdempotency(ctx context.Context, meetingID string, uid t.Uid, idempotencyKey string) (*t.MeetingNoteFile, error) {
row := a.db.QueryRowxContext(ctx, `SELECT * FROM im_meeting_note_files WHERE meeting_id=? AND uploader_id=? AND idempotency_key=? LIMIT 1`, meetingID, decodeUID(uid), idempotencyKey)
item, err := scanMeetingNoteFileRow(row)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return &item, nil
}
func scanMeetingNoteFileRow(scanner interface{ StructScan(any) error }) (t.MeetingNoteFile, error) {
row := struct {
ID int64
NoteFileID string `db:"note_file_id"`
MeetingID string `db:"meeting_id"`
FileID string `db:"file_id"`
UploaderID int64 `db:"uploader_id"`
FileName string `db:"file_name"`
MIMEType string `db:"mime_type"`
FileSize int64 `db:"file_size"`
FileETag string `db:"file_etag"`
Status string
PreviewStatus string `db:"preview_status"`
PreviewMode string `db:"preview_mode"`
PreviewFileID string `db:"preview_file_id"`
IdempotencyKey string `db:"idempotency_key"`
Version int
DeletedBy sql.NullInt64 `db:"deleted_by"`
DeletedAt *time.Time `db:"deleted_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := scanner.StructScan(&row); err != nil {
return t.MeetingNoteFile{}, err
}
item := t.MeetingNoteFile{
ID: row.ID,
NoteFileID: row.NoteFileID,
MeetingID: row.MeetingID,
FileID: row.FileID,
UploaderID: store.EncodeUid(row.UploaderID),
FileName: row.FileName,
MIMEType: row.MIMEType,
FileSize: row.FileSize,
FileETag: row.FileETag,
Status: row.Status,
PreviewStatus: row.PreviewStatus,
PreviewMode: row.PreviewMode,
PreviewFileID: row.PreviewFileID,
IdempotencyKey: row.IdempotencyKey,
Version: row.Version,
DeletedAt: row.DeletedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if row.DeletedBy.Valid {
item.DeletedBy = store.EncodeUid(row.DeletedBy.Int64)
}
return item, nil
}
func uniqueMeetingParticipants(organizer t.Uid, attendeeIDs []t.Uid) []t.Uid {
seen := map[t.Uid]bool{organizer: true}
participants := []t.Uid{organizer}
for _, uid := range attendeeIDs {
if uid.IsZero() || seen[uid] {
continue
}
seen[uid] = true
participants = append(participants, uid)
}
return participants
}
func (a *adapter) TenantAttendanceConfig(id t.TenantID, uid t.Uid, date time.Time) (*t.AttendanceConfig, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantAttendanceConfig(id, uid, date)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || date.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rule, err := a.activeAttendanceRule(ctx)
if err != nil {
return nil, err
}
shifts, err := a.attendanceShifts(ctx, rule.RuleID)
if err != nil {
return nil, err
}
record, err := a.attendanceRecordByDate(ctx, uid, date, "")
if err != nil && err != sql.ErrNoRows {
return nil, err
}
if err == sql.ErrNoRows {
record = nil
}
return &t.AttendanceConfig{
Date: date,
Timezone: "Asia/Shanghai",
Rule: *rule,
Shifts: shifts,
AllowedActions: []string{t.AttendanceActionCheckIn, t.AttendanceActionCheckOut},
TodayRecord: record,
CanViewManaged: true,
}, nil
}
func (a *adapter) TenantAttendanceRecordList(id t.TenantID, uid t.Uid, opts t.AttendanceRecordListOptions) ([]t.AttendanceRecord, string, *t.AttendanceRecordStats, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", nil, err
}
if tenantAdp != a {
return tenantAdp.TenantAttendanceRecordList(id, uid, opts)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", nil, t.ErrMalformed
}
where := []string{"1=1"}
args := []any{}
if opts.Scope == "" || opts.Scope == "mine" {
where = append(where, "user_id=?")
args = append(args, decodeUID(uid))
} else if opts.Scope == "managed" {
if raw := strings.TrimSpace(opts.UserID); raw != "" {
target := t.ParseUserId(raw)
if target.IsZero() {
return nil, "", nil, t.ErrMalformed
}
where = append(where, "user_id=?")
args = append(args, decodeUID(target))
}
} else {
return nil, "", nil, t.ErrMalformed
}
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 != "" {
parsed, err := strconv.ParseInt(opts.Cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", nil, t.ErrMalformed
}
where = append(where, "id<?")
args = append(args, parsed)
}
limit := opts.Limit
if limit <= 0 || limit > 100 {
limit = 50
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
query := `SELECT * FROM im_attendance_records WHERE ` + strings.Join(where, " AND ") + ` ORDER BY id DESC LIMIT ?`
rows, err := a.db.QueryxContext(ctx, query, append(args, limit+1)...)
if err != nil {
return nil, "", nil, err
}
defer rows.Close()
records := make([]t.AttendanceRecord, 0, limit)
nextCursor := ""
for rows.Next() {
record, err := scanAttendanceRecord(rows)
if err != nil {
return nil, "", nil, err
}
if len(records) == limit {
nextCursor = strconv.FormatInt(record.ID, 10)
continue
}
checkins, err := a.attendanceCheckins(ctx, record.RecordID)
if err != nil {
return nil, "", nil, err
}
record.Checkins = checkins
records = append(records, *record)
}
if err = rows.Err(); err != nil {
return nil, "", nil, err
}
stats, err := a.attendanceRecordStats(ctx, opts, uid)
if err != nil {
return nil, "", nil, err
}
return records, nextCursor, stats, nil
}
func (a *adapter) TenantAttendanceCheckin(id t.TenantID, uid t.Uid, checkinID string, req t.AttendanceCheckinRequest) (*t.AttendanceRecord, *t.AttendanceCheckin, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, nil, err
}
if tenantAdp != a {
return tenantAdp.TenantAttendanceCheckin(id, uid, checkinID, req)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(checkinID) == "" || strings.TrimSpace(req.IdempotencyKey) == "" {
return nil, nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if existing, err := a.attendanceCheckinByIdempotency(ctx, uid, req.IdempotencyKey); err == nil {
record, recErr := a.attendanceRecordByID(ctx, existing.RecordID)
return record, existing, recErr
} else if err != sql.ErrNoRows {
return nil, nil, err
}
rule, err := a.activeAttendanceRule(ctx)
if err != nil {
return nil, nil, err
}
shifts, err := a.attendanceShifts(ctx, rule.RuleID)
if err != nil {
return nil, nil, err
}
shift := shifts[0]
if strings.TrimSpace(req.ShiftID) != "" {
for _, candidate := range shifts {
if candidate.ShiftID == strings.TrimSpace(req.ShiftID) {
shift = candidate
break
}
}
}
recordID := t.StableEventID("attendance_record", uid.UserId(), req.Date.Format("2006-01-02"), shift.ShiftID)
result := attendanceCheckinResult(req.Action, req.ClientTime, req.Date, shift)
status := attendanceRecordStatus(req.Action, result, "")
exceptions := attendanceExceptions(status)
locationJSON, _ := json.Marshal(req.Location)
wifiJSON, _ := json.Marshal(req.Wifi)
deviceJSON, _ := json.Marshal(req.Device)
exceptionsJSON, _ := json.Marshal(exceptions)
now := time.Now().UTC().Round(time.Millisecond)
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, nil, err
}
defer func() {
if tx != nil {
tx.Rollback()
}
}()
if _, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_records(record_id,user_id,record_date,rule_id,shift_id,status,exceptions,appeal_status,version)
VALUES(?,?,?,?,?,?,?,?,1)
ON DUPLICATE KEY UPDATE record_id=record_id`, recordID, decodeUID(uid), req.Date.Format("2006-01-02"), rule.RuleID, shift.ShiftID,
t.AttendanceStatusNotChecked, "[]", ""); err != nil {
return nil, nil, err
}
current := struct {
RecordID string `db:"record_id"`
Status string
Version int
}{}
if err = sqlx.GetContext(ctx, tx, &current, `SELECT record_id,status,version FROM im_attendance_records WHERE user_id=? AND record_date=? AND shift_id=?`,
decodeUID(uid), req.Date.Format("2006-01-02"), shift.ShiftID); err != nil {
return nil, nil, err
}
status = attendanceRecordStatus(req.Action, result, current.Status)
exceptionsJSON, _ = json.Marshal(attendanceExceptions(status))
if _, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_checkins(checkin_id,record_id,user_id,record_date,action,rule_id,shift_id,client_time,server_time,result,location,wifi,device,idempotency_key)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, checkinID, current.RecordID, decodeUID(uid), req.Date.Format("2006-01-02"), req.Action, rule.RuleID, shift.ShiftID,
req.ClientTime.UTC().Round(time.Millisecond), now, result, string(locationJSON), string(wifiJSON), string(deviceJSON), req.IdempotencyKey); err != nil {
if isDupe(err) {
tx.Rollback()
tx = nil
existing, lookupErr := a.attendanceCheckinByIdempotency(ctx, uid, req.IdempotencyKey)
if lookupErr != nil {
return nil, nil, lookupErr
}
record, recErr := a.attendanceRecordByID(ctx, existing.RecordID)
return record, existing, recErr
}
return nil, nil, err
}
if _, err = tx.ExecContext(ctx, `UPDATE im_attendance_records SET status=?,exceptions=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE record_id=?`, status, string(exceptionsJSON), current.RecordID); err != nil {
return nil, nil, err
}
if err = tx.Commit(); err != nil {
return nil, nil, err
}
tx = nil
checkin, err := a.attendanceCheckinByIdempotency(ctx, uid, req.IdempotencyKey)
if err != nil {
return nil, nil, err
}
record, err := a.attendanceRecordByID(ctx, checkin.RecordID)
return record, checkin, err
}
func (a *adapter) TenantAttendanceAppealCreate(id t.TenantID, uid t.Uid, appeal *t.AttendanceAppeal, idempotencyKey string) (*t.AttendanceAppeal, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantAttendanceAppealCreate(id, uid, appeal, idempotencyKey)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || appeal == nil || strings.TrimSpace(idempotencyKey) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if existing, err := a.attendanceAppealByIdempotency(ctx, uid, idempotencyKey); err == nil {
return existing, nil
} else if err != sql.ErrNoRows {
return nil, err
}
record, err := a.attendanceRecordByID(ctx, strings.TrimSpace(appeal.RecordID))
if err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
if record.UserID != uid {
return nil, t.ErrPermissionDenied
}
attachmentsJSON, _ := json.Marshal(appeal.AttachmentFileIDs)
now := time.Now().UTC().Round(time.Millisecond)
if appeal.TargetTime != nil {
tm := appeal.TargetTime.UTC().Round(time.Millisecond)
appeal.TargetTime = &tm
}
_, err = a.db.ExecContext(ctx, `INSERT INTO im_attendance_appeals(appeal_id,record_id,user_id,appeal_type,reason,target_action,target_time,attachment_file_ids,status,version,idempotency_key,submitted_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, strings.TrimSpace(appeal.AppealID), strings.TrimSpace(appeal.RecordID), decodeUID(uid), strings.TrimSpace(appeal.AppealType),
strings.TrimSpace(appeal.Reason), strings.TrimSpace(appeal.TargetAction), appeal.TargetTime, string(attachmentsJSON), t.AttendanceAppealPending, 1,
idempotencyKey, now)
if err != nil {
if isDupe(err) {
return a.attendanceAppealByIdempotency(ctx, uid, idempotencyKey)
}
return nil, err
}
_, _ = a.db.ExecContext(ctx, `UPDATE im_attendance_records SET appeal_status=?,updated_at=CURRENT_TIMESTAMP(3) WHERE record_id=?`,
t.AttendanceAppealPending, strings.TrimSpace(appeal.RecordID))
return a.attendanceAppealByIdempotency(ctx, uid, idempotencyKey)
}
func (a *adapter) TenantAttendanceAppealList(id t.TenantID, uid t.Uid, opts t.AttendanceAppealListOptions) ([]t.AttendanceAppeal, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantAttendanceAppealList(id, uid, opts)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
where := []string{"1=1"}
args := []any{}
if opts.Scope == "" || opts.Scope == "mine" {
where = append(where, "user_id=?")
args = append(args, decodeUID(uid))
} else if opts.Scope != "managed" {
return nil, "", t.ErrMalformed
}
if opts.Status != "" {
where = append(where, "status=?")
args = append(args, opts.Status)
}
if opts.Cursor != "" {
parsed, err := strconv.ParseInt(opts.Cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "id<?")
args = append(args, parsed)
}
limit := opts.Limit
if limit <= 0 || limit > 100 {
limit = 50
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_appeals WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, append(args, limit+1)...)
if err != nil {
return nil, "", err
}
defer rows.Close()
appeals := make([]t.AttendanceAppeal, 0, limit)
nextCursor := ""
for rows.Next() {
appeal, err := scanAttendanceAppeal(rows)
if err != nil {
return nil, "", err
}
if len(appeals) == limit {
nextCursor = strconv.FormatInt(appeal.ID, 10)
continue
}
appeals = append(appeals, *appeal)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return appeals, nextCursor, nil
}
func (a *adapter) activeAttendanceRule(ctx context.Context) (*t.AttendanceRule, error) {
row := struct {
ID int64
RuleID string `db:"rule_id"`
Name string
Workday bool
LocationRequired bool `db:"location_required"`
WifiRequired bool `db:"wifi_required"`
AllowOutsideCheckin bool `db:"allow_outside_checkin"`
AppealAllowed bool `db:"appeal_allowed"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT id,rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed,created_at,updated_at
FROM im_attendance_rules WHERE state='active' ORDER BY id ASC LIMIT 1`); err != nil {
if err == sql.ErrNoRows {
now := time.Now().UTC().Round(time.Millisecond)
return &t.AttendanceRule{RuleID: "att_rule_default", Name: "默认考勤规则", Workday: true, AllowOutsideCheckin: true, AppealAllowed: true, CreatedAt: now, UpdatedAt: now}, nil
}
return nil, err
}
return &t.AttendanceRule{
ID: row.ID, RuleID: row.RuleID, Name: row.Name, Workday: row.Workday, LocationRequired: row.LocationRequired,
WifiRequired: row.WifiRequired, AllowOutsideCheckin: row.AllowOutsideCheckin, AppealAllowed: row.AppealAllowed,
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
}, nil
}
func (a *adapter) attendanceShifts(ctx context.Context, ruleID string) ([]t.AttendanceShift, error) {
rows, err := a.db.QueryxContext(ctx, `SELECT id,shift_id,rule_id,name,TIME_FORMAT(work_start,'%H:%i') AS work_start,TIME_FORMAT(work_end,'%H:%i') AS work_end,
TIME_FORMAT(checkin_start,'%H:%i') AS checkin_start,TIME_FORMAT(checkout_end,'%H:%i') AS checkout_end,sort_order,created_at,updated_at
FROM im_attendance_shifts WHERE rule_id=? AND state='active' ORDER BY sort_order ASC,id ASC`, ruleID)
if err != nil {
return nil, err
}
defer rows.Close()
shifts := make([]t.AttendanceShift, 0, 2)
for rows.Next() {
row := struct {
ID int64
ShiftID string `db:"shift_id"`
RuleID string `db:"rule_id"`
Name string `db:"name"`
WorkStart string `db:"work_start"`
WorkEnd string `db:"work_end"`
CheckinStart string `db:"checkin_start"`
CheckoutEnd string `db:"checkout_end"`
SortOrder int `db:"sort_order"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, err
}
shift := t.AttendanceShift{
ID: row.ID, ShiftID: row.ShiftID, RuleID: row.RuleID, Name: row.Name, WorkStart: row.WorkStart,
WorkEnd: row.WorkEnd, CheckinStart: row.CheckinStart, CheckoutEnd: row.CheckoutEnd, SortOrder: row.SortOrder,
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
}
shifts = append(shifts, shift)
}
if err = rows.Err(); err != nil {
return nil, err
}
if len(shifts) == 0 {
now := time.Now().UTC().Round(time.Millisecond)
shifts = append(shifts, t.AttendanceShift{ShiftID: "shift_default", RuleID: ruleID, Name: "默认班次", WorkStart: "09:00", WorkEnd: "18:00", CheckinStart: "08:30", CheckoutEnd: "20:00", SortOrder: 10, CreatedAt: now, UpdatedAt: now})
}
return shifts, nil
}
func (a *adapter) attendanceRecordByDate(ctx context.Context, uid t.Uid, date time.Time, shiftID string) (*t.AttendanceRecord, error) {
query := `SELECT * FROM im_attendance_records WHERE user_id=? AND record_date=?`
args := []any{decodeUID(uid), date.Format("2006-01-02")}
if shiftID != "" {
query += ` AND shift_id=?`
args = append(args, shiftID)
}
query += ` ORDER BY id ASC LIMIT 1`
rows, err := a.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
if rows.Next() {
record, err := scanAttendanceRecord(rows)
if err != nil {
return nil, err
}
record.Checkins, err = a.attendanceCheckins(ctx, record.RecordID)
return record, err
}
if err = rows.Err(); err != nil {
return nil, err
}
return nil, sql.ErrNoRows
}
func (a *adapter) attendanceRecordByID(ctx context.Context, recordID string) (*t.AttendanceRecord, error) {
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_records WHERE record_id=?`, recordID)
if err != nil {
return nil, err
}
defer rows.Close()
if rows.Next() {
record, err := scanAttendanceRecord(rows)
if err != nil {
return nil, err
}
record.Checkins, err = a.attendanceCheckins(ctx, record.RecordID)
return record, err
}
if err = rows.Err(); err != nil {
return nil, err
}
return nil, sql.ErrNoRows
}
func (a *adapter) attendanceCheckins(ctx context.Context, recordID string) ([]t.AttendanceCheckin, error) {
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_checkins WHERE record_id=? ORDER BY id ASC`, recordID)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]t.AttendanceCheckin, 0, 2)
for rows.Next() {
item, err := scanAttendanceCheckin(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
return items, rows.Err()
}
func (a *adapter) attendanceCheckinByIdempotency(ctx context.Context, uid t.Uid, key string) (*t.AttendanceCheckin, error) {
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_checkins WHERE user_id=? AND idempotency_key=?`, decodeUID(uid), key)
if err != nil {
return nil, err
}
defer rows.Close()
if rows.Next() {
return scanAttendanceCheckin(rows)
}
if err = rows.Err(); err != nil {
return nil, err
}
return nil, sql.ErrNoRows
}
func (a *adapter) attendanceAppealByIdempotency(ctx context.Context, uid t.Uid, key string) (*t.AttendanceAppeal, error) {
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_appeals WHERE user_id=? AND idempotency_key=?`, decodeUID(uid), key)
if err != nil {
return nil, err
}
defer rows.Close()
if rows.Next() {
return scanAttendanceAppeal(rows)
}
if err = rows.Err(); err != nil {
return nil, err
}
return nil, sql.ErrNoRows
}
func (a *adapter) attendanceRecordStats(ctx context.Context, opts t.AttendanceRecordListOptions, viewer t.Uid) (*t.AttendanceRecordStats, error) {
where := []string{"1=1"}
args := []any{}
if opts.Scope == "" || opts.Scope == "mine" {
where = append(where, "user_id=?")
args = append(args, decodeUID(viewer))
}
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)
}
stats := &t.AttendanceRecordStats{}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_attendance_records WHERE `+strings.Join(where, " AND "), args...).Scan(&stats.CheckedCount); err != nil {
return nil, err
}
exceptionWhere := append([]string{}, where...)
exceptionWhere = append(exceptionWhere, "status IN (?,?,?)")
exceptionArgs := append(append([]any{}, args...), t.AttendanceStatusLate, t.AttendanceStatusEarlyLeave, t.AttendanceStatusAbnormal)
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_attendance_records WHERE `+strings.Join(exceptionWhere, " AND "), exceptionArgs...).Scan(&stats.ExceptionCount); err != nil {
return nil, err
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE employment_state='active'`).Scan(&stats.ExpectedCount); err != nil {
stats.ExpectedCount = stats.CheckedCount
}
if opts.Scope == "" || opts.Scope == "mine" {
stats.ExpectedCount = 1
}
stats.NotCheckedCount = stats.ExpectedCount - stats.CheckedCount
if stats.NotCheckedCount < 0 {
stats.NotCheckedCount = 0
}
return stats, nil
}
func scanAttendanceRecord(rows *sqlx.Rows) (*t.AttendanceRecord, error) {
row := struct {
ID int64
RecordID string `db:"record_id"`
UserID int64 `db:"user_id"`
Date time.Time `db:"record_date"`
RuleID string `db:"rule_id"`
ShiftID string `db:"shift_id"`
Status string
Exceptions []byte
AppealStatus string `db:"appeal_status"`
Version int
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := rows.StructScan(&row); err != nil {
return nil, err
}
record := &t.AttendanceRecord{
ID: row.ID, RecordID: row.RecordID, UserID: store.EncodeUid(row.UserID), Date: row.Date,
RuleID: row.RuleID, ShiftID: row.ShiftID, Status: row.Status, AppealStatus: row.AppealStatus,
Version: row.Version, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
}
if len(row.Exceptions) > 0 {
_ = json.Unmarshal(row.Exceptions, &record.Exceptions)
}
return record, nil
}
func scanAttendanceCheckin(rows *sqlx.Rows) (*t.AttendanceCheckin, error) {
row := struct {
ID int64
CheckinID string `db:"checkin_id"`
RecordID string `db:"record_id"`
UserID int64 `db:"user_id"`
Date time.Time `db:"record_date"`
Action string
RuleID string `db:"rule_id"`
ShiftID string `db:"shift_id"`
ClientTime time.Time `db:"client_time"`
ServerTime time.Time `db:"server_time"`
Result string
Location []byte
Wifi []byte
Device []byte
IdempotencyKey string `db:"idempotency_key"`
CreatedAt time.Time `db:"created_at"`
}{}
if err := rows.StructScan(&row); err != nil {
return nil, err
}
item := &t.AttendanceCheckin{
ID: row.ID, CheckinID: row.CheckinID, RecordID: row.RecordID, UserID: store.EncodeUid(row.UserID), Date: row.Date,
Action: row.Action, RuleID: row.RuleID, ShiftID: row.ShiftID, ClientTime: row.ClientTime, ServerTime: row.ServerTime,
Result: row.Result, IdempotencyKey: row.IdempotencyKey, CreatedAt: row.CreatedAt,
}
_ = json.Unmarshal(row.Location, &item.Location)
_ = json.Unmarshal(row.Wifi, &item.Wifi)
_ = json.Unmarshal(row.Device, &item.Device)
return item, nil
}
func scanAttendanceAppeal(rows *sqlx.Rows) (*t.AttendanceAppeal, error) {
row := struct {
ID int64
AppealID string `db:"appeal_id"`
RecordID string `db:"record_id"`
UserID int64 `db:"user_id"`
AppealType string `db:"appeal_type"`
Reason string
TargetAction string `db:"target_action"`
TargetTime *time.Time `db:"target_time"`
AttachmentFileIDs []byte `db:"attachment_file_ids"`
Status string
Version int
IdempotencyKey string `db:"idempotency_key"`
SubmittedAt time.Time `db:"submitted_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := rows.StructScan(&row); err != nil {
return nil, err
}
appeal := &t.AttendanceAppeal{
ID: row.ID, AppealID: row.AppealID, RecordID: row.RecordID, UserID: store.EncodeUid(row.UserID),
AppealType: row.AppealType, Reason: row.Reason, TargetAction: row.TargetAction, TargetTime: row.TargetTime,
Status: row.Status, Version: row.Version, SubmittedAt: row.SubmittedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
}
if len(row.AttachmentFileIDs) > 0 {
_ = json.Unmarshal(row.AttachmentFileIDs, &appeal.AttachmentFileIDs)
}
return appeal, nil
}
func attendanceCheckinResult(action string, clientTime, date time.Time, shift t.AttendanceShift) string {
switch action {
case t.AttendanceActionCheckIn:
if clientTime.After(attendanceTimeOnDate(date, shift.WorkStart)) {
return t.AttendanceCheckinLate
}
case t.AttendanceActionCheckOut:
if clientTime.Before(attendanceTimeOnDate(date, shift.WorkEnd)) {
return t.AttendanceCheckinEarlyLeave
}
}
return t.AttendanceCheckinNormal
}
func attendanceRecordStatus(action, result, current string) string {
if result == t.AttendanceCheckinLate {
return t.AttendanceStatusLate
}
if result == t.AttendanceCheckinEarlyLeave {
return t.AttendanceStatusEarlyLeave
}
if current == t.AttendanceStatusLate || current == t.AttendanceStatusEarlyLeave || current == t.AttendanceStatusAbnormal {
return current
}
if action == t.AttendanceActionCheckOut {
return t.AttendanceStatusNormal
}
return t.AttendanceStatusCheckedIn
}
func attendanceExceptions(status string) []string {
switch status {
case t.AttendanceStatusLate:
return []string{"late"}
case t.AttendanceStatusEarlyLeave:
return []string{"early_leave"}
case t.AttendanceStatusAbnormal:
return []string{"abnormal"}
default:
return []string{}
}
}
func attendanceTimeOnDate(date time.Time, clock string) time.Time {
parts := strings.Split(clock, ":")
hour, minute := 0, 0
if len(parts) >= 2 {
hour, _ = strconv.Atoi(parts[0])
minute, _ = strconv.Atoi(parts[1])
}
loc := time.FixedZone("CST", 8*3600)
localDate := date.In(loc)
return time.Date(localDate.Year(), localDate.Month(), localDate.Day(), hour, minute, 0, 0, loc).UTC()
}
func (a *adapter) TenantDailyReportList(id t.TenantID, uid t.Uid, scope, from, to, status, cursor string, limit int) ([]t.DailyReport, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantDailyReportList(id, uid, scope, from, to, status, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
where := []string{"1=1"}
args := []any{}
if scope == "" || scope == "mine" {
where = append(where, "author_id=?")
args = append(args, decodeUID(uid))
} else if scope == "managed" {
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, "", err
}
if !canUseManagedReports(visibility) {
return nil, "", t.ErrPermissionDenied
}
} else {
return nil, "", t.ErrMalformed
}
if from != "" {
where = append(where, "report_date>=?")
args = append(args, from)
}
if to != "" {
where = append(where, "report_date<=?")
args = append(args, to)
}
if status != "" {
where = append(where, "status=?")
args = append(args, status)
}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "id<?")
args = append(args, parsed)
}
args = append(args, limit+1)
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_daily_reports WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
reports := make([]t.DailyReport, 0, limit)
nextCursor := ""
for rows.Next() {
report, err := scanDailyReport(rows)
if err != nil {
return nil, "", err
}
if len(reports) == limit {
nextCursor = strconv.FormatInt(report.ID, 10)
continue
}
reports = append(reports, *report)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return reports, nextCursor, nil
}
func (a *adapter) TenantDailyReportCreateDraft(id t.TenantID, uid t.Uid, reportID string, reportDate time.Time) (*t.DailyReport, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantDailyReportCreateDraft(id, uid, reportID, reportDate)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(reportID) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx, `INSERT INTO im_daily_reports(report_id,author_id,report_date,content,attachments,status,version)
VALUES(?,?,?,?,?,?,1)`, reportID, decodeUID(uid), reportDate.Format("2006-01-02"), "", "[]", t.DailyReportStatusDraft)
if err != nil && !isDupe(err) {
return nil, err
}
row := struct {
ReportID string `db:"report_id"`
}{}
if err = sqlx.GetContext(ctx, a.db, &row, `SELECT report_id FROM im_daily_reports WHERE author_id=? AND report_date=?`, decodeUID(uid), reportDate.Format("2006-01-02")); err != nil {
return nil, err
}
return a.TenantDailyReportGet(id, uid, row.ReportID)
}
func (a *adapter) TenantDailyReportGet(id t.TenantID, uid t.Uid, reportID string) (*t.DailyReport, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantDailyReportGet(id, uid, reportID)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(reportID) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_daily_reports WHERE report_id=? AND author_id=?`, reportID, decodeUID(uid))
if err != nil {
return nil, err
}
defer rows.Close()
if rows.Next() {
return scanDailyReport(rows)
}
if err = rows.Err(); err != nil {
return nil, err
}
return nil, t.ErrNotFound
}
func (a *adapter) TenantDailyReportSaveDraft(id t.TenantID, uid t.Uid, reportID, content string, attachments []string, version int) (*t.DailyReport, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantDailyReportSaveDraft(id, uid, reportID, content, attachments, version)
}
}
encoded, err := json.Marshal(attachments)
if err != nil {
return nil, err
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, `UPDATE im_daily_reports SET content=?,attachments=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE report_id=? AND author_id=? AND status=? AND version=?`, content, string(encoded), reportID, decodeUID(uid), t.DailyReportStatusDraft, version)
if err != nil {
return nil, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.TenantDailyReportGet(id, uid, reportID)
}
func (a *adapter) TenantDailyReportSubmit(id t.TenantID, uid t.Uid, reportID string, version int) (*t.DailyReport, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantDailyReportSubmit(id, uid, reportID, version)
}
}
now := time.Now().UTC().Round(time.Millisecond)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, `UPDATE im_daily_reports SET status=?,submitted_at=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE report_id=? AND author_id=? AND version=?`, t.DailyReportStatusSubmitted, now, reportID, decodeUID(uid), version)
if err != nil {
return nil, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.TenantDailyReportGet(id, uid, reportID)
}
func (a *adapter) TenantDailyReportStats(id t.TenantID, uid t.Uid, date time.Time, departmentID string) (*t.DailyReportStats, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantDailyReportStats(id, uid, date, departmentID)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, err
}
memberWhere, memberArgs := reportMemberScopeWhere(visibility, departmentID)
stats := &t.DailyReportStats{}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT m.user_id) FROM im_org_members AS m WHERE `+strings.Join(memberWhere, " AND "), memberArgs...).Scan(&stats.ExpectedCount); err != nil {
return nil, err
}
submittedWhere := []string{"r.report_date=?", "r.status=?"}
submittedArgs := []any{date.Format("2006-01-02"), t.DailyReportStatusSubmitted}
if len(memberWhere) > 0 {
submittedWhere = append(submittedWhere, `EXISTS (SELECT 1 FROM im_org_members AS m WHERE m.user_id=r.author_id AND `+strings.Join(memberWhere, " AND ")+`)`)
submittedArgs = append(submittedArgs, memberArgs...)
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_daily_reports AS r WHERE `+strings.Join(submittedWhere, " AND "), submittedArgs...).Scan(&stats.SubmittedCount); err != nil {
return nil, err
}
stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount
if stats.MissingCount < 0 {
stats.MissingCount = 0
}
return stats, nil
}
func reportMemberScopeWhere(visibility organizationVisibility, departmentID string) ([]string, []any) {
memberWhere := []string{"m.employment_state='active'"}
memberArgs := []any{}
if strings.TrimSpace(departmentID) != "" {
memberWhere = append(memberWhere, "m.department_id=?")
memberArgs = append(memberArgs, strings.TrimSpace(departmentID))
}
visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m")
if visibilitySQL != "" {
memberWhere = append(memberWhere, visibilitySQL)
memberArgs = append(memberArgs, visibilityArgs...)
}
return memberWhere, memberArgs
}
func canUseManagedReports(visibility organizationVisibility) bool {
return visibility.Scope == orgVisibilityAll
}
func (a *adapter) TenantDailyReportRemind(id t.TenantID, uid t.Uid, date time.Time, userIDs []t.Uid, idempotencyKey string) ([]t.Uid, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantDailyReportRemind(id, uid, date, userIDs, idempotencyKey)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || date.IsZero() || len(userIDs) == 0 || strings.TrimSpace(idempotencyKey) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, err
}
if !canUseManagedReports(visibility) {
return nil, t.ErrPermissionDenied
}
targetIDs := make([]int64, 0, len(userIDs))
seen := map[int64]struct{}{}
for _, target := range userIDs {
if target.IsZero() {
return nil, t.ErrMalformed
}
decoded := decodeUID(target)
if decoded <= 0 {
return nil, t.ErrMalformed
}
if _, ok := seen[decoded]; ok {
continue
}
seen[decoded] = struct{}{}
targetIDs = append(targetIDs, decoded)
}
query, args, err := sqlx.In(`SELECT DISTINCT m.user_id FROM im_org_members AS m
WHERE m.employment_state='active' AND m.user_id IN (?) AND NOT EXISTS (
SELECT 1 FROM im_daily_reports AS r
WHERE r.author_id=m.user_id AND r.report_date=? AND r.status=?
)`, targetIDs, date.Format("2006-01-02"), t.DailyReportStatusSubmitted)
if err != nil {
return nil, err
}
query = a.db.Rebind(query)
rows, err := a.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
reminded := make([]t.Uid, 0, len(targetIDs))
for rows.Next() {
var raw int64
if err = rows.Scan(&raw); err != nil {
return nil, err
}
target := store.EncodeUid(raw)
reminded = append(reminded, target)
}
if err = rows.Err(); err != nil {
return nil, err
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
for _, target := range reminded {
eventID := t.StableEventID("daily_report_reminder", date.Format("2006-01-02"), target.UserId(), strings.TrimSpace(idempotencyKey))
if err = insertSystemNotificationTx(tx, target, eventID, "daily_report.reminded", "日报提醒", date.Format("2006-01-02"), map[string]any{
"date": date.Format("2006-01-02"),
}); err != nil {
return nil, err
}
}
if err = tx.Commit(); err != nil {
return nil, err
}
committed = true
return reminded, nil
}
func scanDailyReport(rows *sqlx.Rows) (*t.DailyReport, error) {
row := struct {
ID int64
ReportID string `db:"report_id"`
AuthorID int64 `db:"author_id"`
ReportDate time.Time `db:"report_date"`
Content string
Attachments []byte
Status string
ReadState string `db:"read_state"`
Version int
SubmittedAt *time.Time `db:"submitted_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := rows.StructScan(&row); err != nil {
return nil, err
}
report := &t.DailyReport{
ID: row.ID,
ReportID: row.ReportID,
AuthorID: store.EncodeUid(row.AuthorID),
ReportDate: row.ReportDate,
Content: row.Content,
Status: row.Status,
ReadState: row.ReadState,
Version: row.Version,
SubmittedAt: row.SubmittedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if len(row.Attachments) > 0 {
_ = json.Unmarshal(row.Attachments, &report.Attachments)
}
return report, nil
}
func (a *adapter) TenantMonthlyReportList(id t.TenantID, uid t.Uid, scope, fromMonth, toMonth, status, cursor string, limit int) ([]t.MonthlyReport, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantMonthlyReportList(id, uid, scope, fromMonth, toMonth, status, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
where := []string{"1=1"}
args := []any{}
if scope == "" || scope == "mine" {
where = append(where, "author_id=?")
args = append(args, decodeUID(uid))
} else if scope == "managed" {
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, "", err
}
if !canUseManagedReports(visibility) {
return nil, "", t.ErrPermissionDenied
}
} else {
return nil, "", t.ErrMalformed
}
if fromMonth != "" {
where = append(where, "report_month>=?")
args = append(args, fromMonth)
}
if toMonth != "" {
where = append(where, "report_month<=?")
args = append(args, toMonth)
}
if status != "" {
where = append(where, "status=?")
args = append(args, status)
}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "id<?")
args = append(args, parsed)
}
args = append(args, limit+1)
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_monthly_reports WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
reports := make([]t.MonthlyReport, 0, limit)
nextCursor := ""
for rows.Next() {
report, err := scanMonthlyReport(rows)
if err != nil {
return nil, "", err
}
if len(reports) == limit {
nextCursor = strconv.FormatInt(report.ID, 10)
continue
}
reports = append(reports, *report)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return reports, nextCursor, nil
}
func (a *adapter) TenantMonthlyReportCreateDraft(id t.TenantID, uid t.Uid, reportID, month string) (*t.MonthlyReport, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMonthlyReportCreateDraft(id, uid, reportID, month)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(reportID) == "" || strings.TrimSpace(month) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx, `INSERT INTO im_monthly_reports(report_id,author_id,report_month,work_summary,next_plan,attachments,status,version)
VALUES(?,?,?,?,?,?,?,1)`, reportID, decodeUID(uid), month, "", "", "[]", t.DailyReportStatusDraft)
if err != nil && !isDupe(err) {
return nil, err
}
row := struct {
ReportID string `db:"report_id"`
}{}
if err = sqlx.GetContext(ctx, a.db, &row, `SELECT report_id FROM im_monthly_reports WHERE author_id=? AND report_month=?`, decodeUID(uid), month); err != nil {
return nil, err
}
return a.TenantMonthlyReportGet(id, uid, row.ReportID)
}
func (a *adapter) TenantMonthlyReportGet(id t.TenantID, uid t.Uid, reportID string) (*t.MonthlyReport, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMonthlyReportGet(id, uid, reportID)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(reportID) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_monthly_reports WHERE report_id=? AND author_id=?`, reportID, decodeUID(uid))
if err != nil {
return nil, err
}
defer rows.Close()
if rows.Next() {
return scanMonthlyReport(rows)
}
if err = rows.Err(); err != nil {
return nil, err
}
return nil, t.ErrNotFound
}
func (a *adapter) TenantMonthlyReportSaveDraft(id t.TenantID, uid t.Uid, reportID, workSummary, nextPlan string, attachments []string, version int) (*t.MonthlyReport, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMonthlyReportSaveDraft(id, uid, reportID, workSummary, nextPlan, attachments, version)
}
}
encoded, err := json.Marshal(attachments)
if err != nil {
return nil, err
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, `UPDATE im_monthly_reports SET work_summary=?,next_plan=?,attachments=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE report_id=? AND author_id=? AND status=? AND version=?`, workSummary, nextPlan, string(encoded), reportID, decodeUID(uid), t.DailyReportStatusDraft, version)
if err != nil {
return nil, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.TenantMonthlyReportGet(id, uid, reportID)
}
func (a *adapter) TenantMonthlyReportSubmit(id t.TenantID, uid t.Uid, reportID string, version int) (*t.MonthlyReport, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMonthlyReportSubmit(id, uid, reportID, version)
}
}
now := time.Now().UTC().Round(time.Millisecond)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, `UPDATE im_monthly_reports SET status=?,submitted_at=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE report_id=? AND author_id=? AND version=?`, t.DailyReportStatusSubmitted, now, reportID, decodeUID(uid), version)
if err != nil {
return nil, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.TenantMonthlyReportGet(id, uid, reportID)
}
func (a *adapter) TenantMonthlyReportReopen(id t.TenantID, uid t.Uid, reportID string, version int) (*t.MonthlyReport, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMonthlyReportReopen(id, uid, reportID, version)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(reportID) == "" || version <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, `UPDATE im_monthly_reports
SET status=?,submitted_at=NULL,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE report_id=? AND author_id=? AND status=? AND version=?`,
t.DailyReportStatusDraft, reportID, decodeUID(uid), t.DailyReportStatusSubmitted, version)
if err != nil {
return nil, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.TenantMonthlyReportGet(id, uid, reportID)
}
func (a *adapter) TenantMonthlyReportStats(id t.TenantID, uid t.Uid, month, departmentID string) (*t.MonthlyReportStats, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMonthlyReportStats(id, uid, month, departmentID)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, err
}
memberWhere, memberArgs := reportMemberScopeWhere(visibility, departmentID)
stats := &t.MonthlyReportStats{}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT m.user_id) FROM im_org_members AS m WHERE `+strings.Join(memberWhere, " AND "), memberArgs...).Scan(&stats.ExpectedCount); err != nil {
return nil, err
}
submittedWhere := []string{"r.report_month=?", "r.status=?"}
submittedArgs := []any{month, t.DailyReportStatusSubmitted}
if len(memberWhere) > 0 {
submittedWhere = append(submittedWhere, `EXISTS (SELECT 1 FROM im_org_members AS m WHERE m.user_id=r.author_id AND `+strings.Join(memberWhere, " AND ")+`)`)
submittedArgs = append(submittedArgs, memberArgs...)
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_monthly_reports AS r WHERE `+strings.Join(submittedWhere, " AND "), submittedArgs...).Scan(&stats.SubmittedCount); err != nil {
return nil, err
}
stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount
if stats.MissingCount < 0 {
stats.MissingCount = 0
}
return stats, nil
}
func scanMonthlyReport(rows *sqlx.Rows) (*t.MonthlyReport, error) {
row := struct {
ID int64
ReportID string `db:"report_id"`
AuthorID int64 `db:"author_id"`
Month string `db:"report_month"`
WorkSummary string `db:"work_summary"`
NextPlan string `db:"next_plan"`
Attachments []byte
Status string
ReadState string `db:"read_state"`
Version int
SubmittedAt *time.Time `db:"submitted_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := rows.StructScan(&row); err != nil {
return nil, err
}
report := &t.MonthlyReport{
ID: row.ID,
ReportID: row.ReportID,
AuthorID: store.EncodeUid(row.AuthorID),
Month: row.Month,
WorkSummary: row.WorkSummary,
NextPlan: row.NextPlan,
Status: row.Status,
ReadState: row.ReadState,
Version: row.Version,
SubmittedAt: row.SubmittedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if len(row.Attachments) > 0 {
_ = json.Unmarshal(row.Attachments, &report.Attachments)
}
return report, nil
}
func (a *adapter) TenantMailboxConfigGet(id t.TenantID, uid t.Uid) (*t.MailboxConfig, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMailboxConfigGet(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
return a.mailboxConfig(ctx)
}
func (a *adapter) TenantMailboxSubmissionCreate(id t.TenantID, uid t.Uid, submission *t.MailboxSubmission, idempotencyKey string) (*t.MailboxSubmission, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMailboxSubmissionCreate(id, uid, submission, idempotencyKey)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || submission == nil {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
existing, err := a.mailboxSubmissionByIdempotency(ctx, uid, idempotencyKey)
if err == nil {
return existing, nil
}
if err != t.ErrNotFound {
return nil, err
}
config, err := a.mailboxConfig(ctx)
if err != nil {
return nil, err
}
if !config.Enabled || (submission.Anonymous && !config.AllowAnonymous) || len([]rune(submission.Content)) > config.MaxLength {
return nil, t.ErrPolicy
}
currentNoticeVersion := config.Notice.UpdatedAt.UTC().Format(time.RFC3339Nano)
if submission.NoticeVersion != "" && submission.NoticeVersion != currentNoticeVersion {
return nil, t.ErrInvalidResponse
}
attachments, err := json.Marshal(submission.AttachmentFileIDs)
if err != nil {
return nil, err
}
now := time.Now().UTC().Round(time.Millisecond)
_, err = a.db.ExecContext(ctx, `INSERT INTO im_mailbox_submissions(submission_id,sender_id,subject,content,anonymous,attachment_file_ids,notice_version,idempotency_key,status,submitted_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, submission.SubmissionID, decodeUID(uid), strings.TrimSpace(submission.Subject), strings.TrimSpace(submission.Content), submission.Anonymous,
string(attachments), currentNoticeVersion, idempotencyKey, t.MailboxSubmissionStatusReceived, now)
if err != nil {
if isDupe(err) {
return a.mailboxSubmissionByIdempotency(ctx, uid, idempotencyKey)
}
return nil, err
}
return a.mailboxSubmissionById(ctx, submission.SubmissionID)
}
func (a *adapter) mailboxConfig(ctx context.Context) (*t.MailboxConfig, error) {
row := struct {
Enabled bool
AllowAnonymous bool `db:"allow_anonymous"`
RequiredFields []byte `db:"required_fields"`
MaxLength int `db:"max_length"`
AttachmentPolicy []byte `db:"attachment_policy"`
NoticeTitle string `db:"notice_title"`
NoticeContent string `db:"notice_content"`
NoticeUpdatedAt time.Time `db:"notice_updated_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row, `
SELECT enabled,allow_anonymous,required_fields,max_length,attachment_policy,
notice_title,notice_content,notice_updated_at,updated_at
FROM im_mailbox_config WHERE id=1`); err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
config := &t.MailboxConfig{
Enabled: row.Enabled,
AllowAnonymous: row.AllowAnonymous,
MaxLength: row.MaxLength,
Notice: t.MailboxNotice{
Title: row.NoticeTitle,
Content: row.NoticeContent,
UpdatedAt: row.NoticeUpdatedAt,
},
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.RequiredFields, &config.RequiredFields)
_ = json.Unmarshal(row.AttachmentPolicy, &config.AttachmentPolicy)
return config, nil
}
func (a *adapter) mailboxSubmissionByIdempotency(ctx context.Context, uid t.Uid, idempotencyKey string) (*t.MailboxSubmission, error) {
row := struct {
SubmissionID string `db:"submission_id"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT submission_id FROM im_mailbox_submissions WHERE sender_id=? AND idempotency_key=?`, decodeUID(uid), idempotencyKey); err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
return a.mailboxSubmissionById(ctx, row.SubmissionID)
}
func (a *adapter) mailboxSubmissionById(ctx context.Context, submissionID string) (*t.MailboxSubmission, error) {
row := struct {
ID int64
SubmissionID string `db:"submission_id"`
SenderID int64 `db:"sender_id"`
Subject string
Content string
Anonymous bool
AttachmentFileIDs []byte `db:"attachment_file_ids"`
NoticeVersion string `db:"notice_version"`
Status string
SubmittedAt time.Time `db:"submitted_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row, `
SELECT id,submission_id,sender_id,subject,content,anonymous,attachment_file_ids,
notice_version,status,submitted_at,created_at,updated_at
FROM im_mailbox_submissions WHERE submission_id=?`, submissionID); err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
submission := &t.MailboxSubmission{
ID: row.ID,
SubmissionID: row.SubmissionID,
SenderID: store.EncodeUid(row.SenderID),
Subject: row.Subject,
Content: row.Content,
Anonymous: row.Anonymous,
NoticeVersion: row.NoticeVersion,
Status: row.Status,
SubmittedAt: row.SubmittedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.AttachmentFileIDs, &submission.AttachmentFileIDs)
return submission, nil
}
func (a *adapter) TenantUserProfileGet(id t.TenantID, uid t.Uid) (*t.UserProfile, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantUserProfileGet(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
profileID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(id,user_id,public_account) VALUES(?,?,?)`, profileID, decodeUID(uid), uid.UserId()); err != nil {
return nil, err
}
return a.userProfileByUID(ctx, uid)
}
func (a *adapter) TenantUserProfileUpdate(id t.TenantID, uid t.Uid, patch t.UserProfilePatch) (*t.UserProfile, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantUserProfileUpdate(id, uid, patch)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || patch.Version <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
profileID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(id,user_id,public_account) VALUES(?,?,?)`, profileID, decodeUID(uid), uid.UserId()); err != nil {
return nil, err
}
sets := []string{"version=version+1", "updated_at=CURRENT_TIMESTAMP(3)"}
args := make([]any, 0, 10)
if patch.AvatarFileID != nil {
sets = append(sets, "avatar_file_id=?")
args = append(args, strings.TrimSpace(*patch.AvatarFileID))
}
if patch.Nickname != nil {
sets = append(sets, "nickname=?")
args = append(args, strings.TrimSpace(*patch.Nickname))
}
if patch.BirthDate != nil {
birth := strings.TrimSpace(*patch.BirthDate)
if birth == "" {
sets = append(sets, "birth_date=NULL")
} else {
sets = append(sets, "birth_date=?")
args = append(args, birth)
}
}
if patch.Signature != nil {
sets = append(sets, "signature=?")
args = append(args, strings.TrimSpace(*patch.Signature))
}
if patch.Gender != nil {
sets = append(sets, "gender=?")
args = append(args, strings.TrimSpace(*patch.Gender))
}
if patch.Region != nil {
sets = append(sets, "region=?")
args = append(args, strings.TrimSpace(*patch.Region))
}
if patch.Email != nil {
sets = append(sets, "email=?")
args = append(args, strings.TrimSpace(*patch.Email))
}
args = append(args, decodeUID(uid), patch.Version)
res, err := a.db.ExecContext(ctx, `UPDATE im_user_profiles SET `+strings.Join(sets, ",")+` WHERE user_id=? AND version=?`, args...)
if err != nil {
return nil, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.userProfileByUID(ctx, uid)
}
func (a *adapter) TenantUserPrivacyGet(id t.TenantID, uid t.Uid) (*t.UserPrivacySettings, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantUserPrivacyGet(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
privacyID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_privacy_settings(id,user_id,add_me_methods) VALUES(?,?,'{"account":true,"phone":true,"qr":true,"group":true}')`, privacyID, decodeUID(uid)); err != nil {
return nil, err
}
return a.userPrivacyByUID(ctx, uid)
}
func (a *adapter) TenantUserPrivacyUpdate(id t.TenantID, uid t.Uid, patch t.UserPrivacyPatch) (*t.UserPrivacySettings, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantUserPrivacyUpdate(id, uid, patch)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || patch.Version <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
privacyID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_privacy_settings(id,user_id,add_me_methods) VALUES(?,?,'{"account":true,"phone":true,"qr":true,"group":true}')`, privacyID, decodeUID(uid)); err != nil {
return nil, err
}
sets := []string{"version=version+1", "updated_at=CURRENT_TIMESTAMP(3)"}
args := make([]any, 0, 8)
if patch.AddMeMethods != nil {
encoded, err := json.Marshal(patch.AddMeMethods)
if err != nil {
return nil, err
}
sets = append(sets, "add_me_methods=?")
args = append(args, string(encoded))
}
if patch.ProfileVisibility != nil {
sets = append(sets, "profile_visibility=?")
args = append(args, strings.TrimSpace(*patch.ProfileVisibility))
}
if patch.LastSeenVisibility != nil {
sets = append(sets, "last_seen_visibility=?")
args = append(args, strings.TrimSpace(*patch.LastSeenVisibility))
}
if patch.ReadReceipts != nil {
sets = append(sets, "read_receipts=?")
args = append(args, *patch.ReadReceipts)
}
args = append(args, decodeUID(uid), patch.Version)
res, err := a.db.ExecContext(ctx, `UPDATE im_user_privacy_settings SET `+strings.Join(sets, ",")+` WHERE user_id=? AND version=?`, args...)
if err != nil {
return nil, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.userPrivacyByUID(ctx, uid)
}
func (a *adapter) TenantAppReleaseLatest(id t.TenantID, platform, channel string) (*t.AppRelease, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantAppReleaseLatest(id, platform, channel)
}
}
platform = strings.ToLower(strings.TrimSpace(platform))
channel = strings.ToLower(strings.TrimSpace(channel))
if err := validTenant(id); err != nil || platform == "" || channel == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
row := struct {
ID int64 `db:"id"`
Platform string `db:"platform"`
Channel string `db:"channel"`
LatestVersion string `db:"latest_version"`
MinSupportedVersion string `db:"min_supported_version"`
UpdateType string `db:"update_type"`
ReleaseNotes string `db:"release_notes"`
StoreURL string `db:"store_url"`
PackageURL string `db:"package_url"`
Checksum string `db:"checksum"`
RemindAfterSeconds int `db:"remind_after_seconds"`
PublishedAt time.Time `db:"published_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
err := sqlx.GetContext(ctx, a.db, &row,
`SELECT id,platform,channel,latest_version,min_supported_version,update_type,release_notes,store_url,package_url,checksum,remind_after_seconds,published_at,created_at,updated_at
FROM im_app_releases WHERE platform=? AND channel=? LIMIT 1`, platform, channel)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return &t.AppRelease{
ID: row.ID,
Platform: row.Platform,
Channel: row.Channel,
LatestVersion: row.LatestVersion,
MinSupportedVersion: row.MinSupportedVersion,
UpdateType: row.UpdateType,
ReleaseNotes: row.ReleaseNotes,
StoreURL: row.StoreURL,
PackageURL: row.PackageURL,
Checksum: row.Checksum,
RemindAfterSeconds: row.RemindAfterSeconds,
PublishedAt: row.PublishedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}, nil
}
func (a *adapter) TenantDevicePushTokenUpsert(id t.TenantID, token *t.DevicePushToken) (*t.DevicePushToken, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantDevicePushTokenUpsert(id, token)
}
}
if err := validTenant(id); err != nil || token == nil || token.UserID.IsZero() ||
strings.TrimSpace(token.DeviceID) == "" || strings.TrimSpace(token.Provider) == "" || strings.TrimSpace(token.Token) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
now := t.TimeNow()
token.DeviceID = strings.TrimSpace(token.DeviceID)
token.Provider = strings.ToLower(strings.TrimSpace(token.Provider))
token.Token = strings.TrimSpace(token.Token)
token.AppVersion = strings.TrimSpace(token.AppVersion)
token.Platform = strings.ToLower(strings.TrimSpace(token.Platform))
tokenID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
_, err = a.db.ExecContext(ctx,
`INSERT INTO im_device_push_tokens(id,device_id,user_id,provider,token,app_version,platform,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE device_id=VALUES(device_id),user_id=VALUES(user_id),provider=VALUES(provider),token=VALUES(token),
app_version=VALUES(app_version),platform=VALUES(platform),updated_at=VALUES(updated_at)`,
tokenID, token.DeviceID, decodeUID(token.UserID), token.Provider, token.Token, token.AppVersion, token.Platform, now, now)
if err != nil {
return nil, err
}
token.CreatedAt = now
token.UpdatedAt = now
return token, nil
}
func (a *adapter) TenantDevicePushTokenGetAll(id t.TenantID, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
return a.tenantDevicePushTokenGetAllByProviders(id, []string{"fcm", "firebase"}, uids...)
}
func (a *adapter) TenantDevicePushTokenGetAllByProvider(id t.TenantID, provider string, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return map[t.Uid][]t.DeviceDef{}, 0, nil
}
return a.tenantDevicePushTokenGetAllByProviders(id, []string{provider}, uids...)
}
func (a *adapter) tenantDevicePushTokenGetAllByProviders(id t.TenantID, providers []string, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, 0, err
}
if tenantAdp != a {
return tenantAdp.tenantDevicePushTokenGetAllByProviders(id, providers, uids...)
}
}
normalizedProviders := make([]any, 0, len(providers))
for _, provider := range providers {
if provider = strings.ToLower(strings.TrimSpace(provider)); provider != "" {
normalizedProviders = append(normalizedProviders, provider)
}
}
if len(normalizedProviders) == 0 {
return map[t.Uid][]t.DeviceDef{}, 0, nil
}
decoded := make([]any, 0, len(uids))
for _, uid := range uids {
if !uid.IsZero() {
decoded = append(decoded, decodeUID(uid))
}
}
if len(decoded) == 0 {
return map[t.Uid][]t.DeviceDef{}, 0, nil
}
query, args, err := sqlx.In(`SELECT user_id,token,platform,updated_at FROM im_device_push_tokens
WHERE provider IN (?) AND user_id IN (?)`, normalizedProviders, decoded)
if err != nil {
return nil, 0, err
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, a.db.Rebind(query), args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
result := make(map[t.Uid][]t.DeviceDef)
count := 0
for rows.Next() {
var userID int64
var token, platform string
var updatedAt time.Time
if err = rows.Scan(&userID, &token, &platform, &updatedAt); err != nil {
return nil, 0, err
}
uid := store.EncodeUid(userID)
result[uid] = append(result[uid], t.DeviceDef{
TenantID: id, DeviceId: token, Platform: strings.ToLower(strings.TrimSpace(platform)), LastSeen: updatedAt,
})
count++
}
return result, count, rows.Err()
}
func (a *adapter) TenantRiskChallengeCreate(id t.TenantID, challenge *t.RiskChallenge) (*t.RiskChallenge, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantRiskChallengeCreate(id, challenge)
}
}
if err := validTenant(id); err != nil || challenge == nil || challenge.UserID.IsZero() ||
strings.TrimSpace(challenge.ChallengeID) == "" || strings.TrimSpace(challenge.Action) == "" || challenge.ExpiresAt.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
params, err := json.Marshal(challenge.NoticeParams)
if err != nil {
return nil, err
}
now := t.TimeNow()
if challenge.State == "" {
challenge.State = t.RiskChallengeStatePending
}
challenge.Level = strings.ToLower(strings.TrimSpace(challenge.Level))
challenge.NoticeCode = strings.TrimSpace(challenge.NoticeCode)
_, err = a.db.ExecContext(ctx,
`INSERT INTO im_risk_challenges(challenge_id,user_id,action,level,notice_code,notice_params,state,expires_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
strings.TrimSpace(challenge.ChallengeID), decodeUID(challenge.UserID), strings.TrimSpace(challenge.Action),
challenge.Level, challenge.NoticeCode, string(params), challenge.State, challenge.ExpiresAt, now, now)
if err != nil {
return nil, err
}
return a.riskChallengeByID(ctx, challenge.ChallengeID)
}
func (a *adapter) TenantRiskChallengeConfirm(id t.TenantID, uid t.Uid, challengeID, decision, actionTokenID string, now time.Time) (*t.RiskChallenge, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantRiskChallengeConfirm(id, uid, challengeID, decision, actionTokenID, now)
}
}
decision = strings.ToLower(strings.TrimSpace(decision))
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(challengeID) == "" ||
(decision != "continue" && decision != "cancel") {
return nil, t.ErrMalformed
}
if now.IsZero() {
now = t.TimeNow()
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if tx != nil {
_ = tx.Rollback()
}
}()
challenge, err := a.riskChallengeByIDTx(ctx, tx, challengeID, true)
if err != nil {
return nil, err
}
if challenge.UserID != uid || challenge.State != t.RiskChallengeStatePending || !now.Before(challenge.ExpiresAt) {
return nil, t.ErrInvalidResponse
}
nextState := t.RiskChallengeStateCanceled
if decision == "continue" {
nextState = t.RiskChallengeStateContinued
}
_, err = tx.ExecContext(ctx,
`UPDATE im_risk_challenges SET state=?,decision=?,action_token_id=?,updated_at=?
WHERE challenge_id=? AND state=?`,
nextState, decision, strings.TrimSpace(actionTokenID), now, challengeID, t.RiskChallengeStatePending)
if err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
tx = nil
return a.riskChallengeByID(ctx, challengeID)
}
func (a *adapter) TenantAbuseReasons(id t.TenantID, targetType string) ([]t.AbuseReason, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantAbuseReasons(id, targetType)
}
}
targetType = strings.ToLower(strings.TrimSpace(targetType))
if err := validTenant(id); err != nil || targetType == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var reasons []t.AbuseReason
err := sqlx.SelectContext(ctx, a.db, &reasons,
`SELECT target_type,reason_code,label,requires_description,allows_evidence,sort_order
FROM im_abuse_reasons WHERE target_type=? AND enabled=1 ORDER BY sort_order,id`, targetType)
return reasons, err
}
func (a *adapter) TenantAbuseReportCreate(id t.TenantID, report *t.AbuseReport, idempotencyKey string) (*t.AbuseReport, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantAbuseReportCreate(id, report, idempotencyKey)
}
}
if err := validTenant(id); err != nil || report == nil || report.ReporterID.IsZero() ||
strings.TrimSpace(report.ReportID) == "" || strings.TrimSpace(report.ReasonCode) == "" || strings.TrimSpace(idempotencyKey) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
evidence, err := json.Marshal(report.EvidenceFileIDs)
if err != nil {
return nil, err
}
snapshot, err := json.Marshal(report.Snapshot)
if err != nil {
return nil, err
}
now := t.TimeNow()
report.Status = t.AbuseReportStatusReceived
_, err = a.db.ExecContext(ctx,
`INSERT INTO im_abuse_reports(report_id,reporter_id,target_type,target_user_id,target_topic,target_seq,reason_code,description,evidence_file_ids,snapshot,idempotency_key,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
report.ReportID, decodeUID(report.ReporterID), report.TargetType, decodeUID(report.TargetUserID), report.TargetTopic,
report.TargetSeq, report.ReasonCode, report.Description, string(evidence), string(snapshot), strings.TrimSpace(idempotencyKey),
report.Status, now, now)
if err != nil {
return nil, err
}
return a.abuseReportByIdempotency(ctx, report.ReporterID, idempotencyKey)
}
func (a *adapter) TenantFeedbackConfigGet(id t.TenantID, uid t.Uid) (*t.FeedbackConfig, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFeedbackConfigGet(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
return a.feedbackConfig(ctx)
}
func (a *adapter) TenantFeedbackCreate(id t.TenantID, feedback *t.FeedbackSubmission, idempotencyKey string) (*t.FeedbackSubmission, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFeedbackCreate(id, feedback, idempotencyKey)
}
}
if err := validTenant(id); err != nil || feedback == nil || feedback.UserID.IsZero() ||
strings.TrimSpace(feedback.FeedbackID) == "" || strings.TrimSpace(feedback.Category) == "" ||
strings.TrimSpace(feedback.Content) == "" || strings.TrimSpace(idempotencyKey) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
attachments, err := json.Marshal(feedback.AttachmentFileIDs)
if err != nil {
return nil, err
}
clientContext, err := json.Marshal(feedback.ClientContext)
if err != nil {
return nil, err
}
now := t.TimeNow()
feedback.Status = t.FeedbackStatusReceived
_, err = a.db.ExecContext(ctx,
`INSERT INTO im_feedback_submissions(feedback_id,user_id,category,content,contact,attachment_file_ids,client_context,include_diagnostics,idempotency_key,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
feedback.FeedbackID, decodeUID(feedback.UserID), feedback.Category, feedback.Content, feedback.Contact,
string(attachments), string(clientContext), feedback.IncludeDiagnostics, strings.TrimSpace(idempotencyKey),
feedback.Status, now, now)
if err != nil {
return nil, err
}
return a.feedbackByIdempotency(ctx, feedback.UserID, idempotencyKey)
}
func (a *adapter) riskChallengeByID(ctx context.Context, challengeID string) (*t.RiskChallenge, error) {
return a.riskChallengeByIDTx(ctx, a.db, challengeID, false)
}
func (a *adapter) riskChallengeByIDTx(ctx context.Context, q sqlx.QueryerContext, challengeID string, forUpdate bool) (*t.RiskChallenge, error) {
row := struct {
ID int64 `db:"id"`
ChallengeID string `db:"challenge_id"`
UserID int64 `db:"user_id"`
Action string `db:"action"`
Level string `db:"level"`
NoticeCode string `db:"notice_code"`
NoticeParams []byte `db:"notice_params"`
State string `db:"state"`
ExpiresAt time.Time `db:"expires_at"`
Decision string `db:"decision"`
ActionTokenID string `db:"action_token_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
query := `SELECT id,challenge_id,user_id,action,level,notice_code,notice_params,state,expires_at,decision,action_token_id,created_at,updated_at
FROM im_risk_challenges WHERE challenge_id=?`
if forUpdate {
query += " FOR UPDATE"
}
if err := sqlx.GetContext(ctx, q, &row, query, challengeID); err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
challenge := &t.RiskChallenge{
ID: row.ID,
ChallengeID: row.ChallengeID,
UserID: store.EncodeUid(row.UserID),
Action: row.Action,
Level: row.Level,
NoticeCode: row.NoticeCode,
NoticeParams: map[string]any{},
State: row.State,
ExpiresAt: row.ExpiresAt,
Decision: row.Decision,
ActionTokenID: row.ActionTokenID,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.NoticeParams, &challenge.NoticeParams)
return challenge, nil
}
func (a *adapter) abuseReportByIdempotency(ctx context.Context, uid t.Uid, idempotencyKey string) (*t.AbuseReport, error) {
row := struct {
ID int64 `db:"id"`
ReportID string `db:"report_id"`
ReporterID int64 `db:"reporter_id"`
TargetType string `db:"target_type"`
TargetUserID int64 `db:"target_user_id"`
TargetTopic string `db:"target_topic"`
TargetSeq int64 `db:"target_seq"`
ReasonCode string `db:"reason_code"`
Description string `db:"description"`
EvidenceFileIDs []byte `db:"evidence_file_ids"`
Snapshot []byte `db:"snapshot"`
Status string `db:"status"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row,
`SELECT id,report_id,reporter_id,target_type,target_user_id,target_topic,target_seq,reason_code,description,evidence_file_ids,snapshot,status,created_at,updated_at
FROM im_abuse_reports WHERE reporter_id=? AND idempotency_key=?`,
decodeUID(uid), strings.TrimSpace(idempotencyKey)); err != nil {
return nil, err
}
report := &t.AbuseReport{
ID: row.ID,
ReportID: row.ReportID,
ReporterID: store.EncodeUid(row.ReporterID),
TargetType: row.TargetType,
TargetUserID: store.EncodeUid(row.TargetUserID),
TargetTopic: row.TargetTopic,
TargetSeq: row.TargetSeq,
ReasonCode: row.ReasonCode,
Description: row.Description,
EvidenceFileIDs: []string{},
Snapshot: map[string]any{},
Status: row.Status,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.EvidenceFileIDs, &report.EvidenceFileIDs)
_ = json.Unmarshal(row.Snapshot, &report.Snapshot)
return report, nil
}
func (a *adapter) feedbackConfig(ctx context.Context) (*t.FeedbackConfig, error) {
row := struct {
Categories []byte `db:"categories"`
MaxLength int `db:"max_length"`
AttachmentPolicy []byte `db:"attachment_policy"`
AllowContact bool `db:"allow_contact"`
DiagnosticsNotice string `db:"diagnostics_notice"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row,
`SELECT categories,max_length,attachment_policy,allow_contact,diagnostics_notice,updated_at
FROM im_feedback_config ORDER BY id LIMIT 1`); err != nil {
if err == sql.ErrNoRows {
now := t.TimeNow()
return &t.FeedbackConfig{
Categories: []string{"bug", "suggestion", "complaint", "other"},
MaxLength: 1000,
AttachmentPolicy: map[string]any{"max_files": float64(5), "purpose": "feedback"},
AllowContact: true,
DiagnosticsNotice: "仅在你同意时上传脱敏诊断信息。",
UpdatedAt: now,
}, nil
}
return nil, err
}
config := &t.FeedbackConfig{
Categories: []string{"bug", "suggestion", "complaint", "other"},
MaxLength: row.MaxLength,
AttachmentPolicy: map[string]any{},
AllowContact: row.AllowContact,
DiagnosticsNotice: row.DiagnosticsNotice,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.Categories, &config.Categories)
_ = json.Unmarshal(row.AttachmentPolicy, &config.AttachmentPolicy)
return config, nil
}
func (a *adapter) feedbackByIdempotency(ctx context.Context, uid t.Uid, idempotencyKey string) (*t.FeedbackSubmission, error) {
row := struct {
ID int64 `db:"id"`
FeedbackID string `db:"feedback_id"`
UserID int64 `db:"user_id"`
Category string `db:"category"`
Content string `db:"content"`
Contact string `db:"contact"`
AttachmentFileIDs []byte `db:"attachment_file_ids"`
ClientContext []byte `db:"client_context"`
IncludeDiagnostics bool `db:"include_diagnostics"`
Status string `db:"status"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row,
`SELECT id,feedback_id,user_id,category,content,contact,attachment_file_ids,client_context,include_diagnostics,status,created_at,updated_at
FROM im_feedback_submissions WHERE user_id=? AND idempotency_key=?`,
decodeUID(uid), strings.TrimSpace(idempotencyKey)); err != nil {
return nil, err
}
feedback := &t.FeedbackSubmission{
ID: row.ID,
FeedbackID: row.FeedbackID,
UserID: store.EncodeUid(row.UserID),
Category: row.Category,
Content: row.Content,
Contact: row.Contact,
AttachmentFileIDs: []string{},
ClientContext: map[string]any{},
IncludeDiagnostics: row.IncludeDiagnostics,
Status: row.Status,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.AttachmentFileIDs, &feedback.AttachmentFileIDs)
_ = json.Unmarshal(row.ClientContext, &feedback.ClientContext)
return feedback, nil
}
func (a *adapter) userProfileByUID(ctx context.Context, uid t.Uid) (*t.UserProfile, error) {
row := struct {
UserID int64 `db:"user_id"`
AvatarFileID string `db:"avatar_file_id"`
Nickname string
BirthDate sql.NullTime `db:"birth_date"`
Signature string
Gender string
Region string
Email string
PublicAccount string `db:"public_account"`
Version int
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT user_id,avatar_file_id,nickname,birth_date,signature,gender,region,email,
public_account,version,created_at,updated_at FROM im_user_profiles WHERE user_id=?`, decodeUID(uid)); err != nil {
return nil, err
}
profile := &t.UserProfile{
UserID: store.EncodeUid(row.UserID),
AvatarFileID: row.AvatarFileID,
Nickname: row.Nickname,
Signature: row.Signature,
Gender: row.Gender,
Region: row.Region,
Email: row.Email,
PublicAccount: row.PublicAccount,
Version: row.Version,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if row.BirthDate.Valid {
profile.BirthDate = row.BirthDate.Time.Format("2006-01-02")
}
return profile, nil
}
func (a *adapter) userPrivacyByUID(ctx context.Context, uid t.Uid) (*t.UserPrivacySettings, error) {
row := struct {
UserID int64 `db:"user_id"`
AddMeMethods []byte `db:"add_me_methods"`
ProfileVisibility string `db:"profile_visibility"`
LastSeenVisibility string `db:"last_seen_visibility"`
ReadReceipts bool `db:"read_receipts"`
Version int
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT user_id,add_me_methods,profile_visibility,last_seen_visibility,
read_receipts,version,created_at,updated_at FROM im_user_privacy_settings WHERE user_id=?`, decodeUID(uid)); err != nil {
return nil, err
}
settings := &t.UserPrivacySettings{
UserID: store.EncodeUid(row.UserID),
AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": true, "group": true},
ProfileVisibility: row.ProfileVisibility,
LastSeenVisibility: row.LastSeenVisibility,
ReadReceipts: row.ReadReceipts,
Version: row.Version,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.AddMeMethods, &settings.AddMeMethods)
return settings, nil
}
func isAllowedWorkbenchRoute(route string) bool {
switch route {
case "/organization", "/attendance", "/meetings", "/meeting-notes", "/reports/daily", "/reports/monthly", "/executive-mailbox":
return true
default:
return false
}
}
func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64, suffix string) (*t.Message, error) {
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
err := sqlx.GetContext(ctx, q, &row,
"SELECT id,createdat,updatedat,deletedat,delid,seqid,topic,`from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content "+
"FROM im_messages WHERE topic=? AND seqid=?"+suffix,
topic, seq)
if err != nil {
return nil, err
}
msg := row.Message
msg.TenantID = tenantID
msg.From = common.EncodeUidString(msg.From).String()
if row.RecalledBy.Valid {
msg.RecalledBy = common.EncodeUidString(strconv.FormatInt(row.RecalledBy.Int64, 10)).String()
}
msg.Content = common.FromJSON(msg.Content)
return &msg, nil
}
func (a *adapter) groupIDByTopic(ctx context.Context, topic string) (int64, error) {
var groupID int64
err := a.db.QueryRowContext(ctx, "SELECT id FROM im_group_settings WHERE topic=? LIMIT 1", topic).Scan(&groupID)
if err == sql.ErrNoRows {
return 0, t.ErrNotFound
}
return groupID, err
}
func tenantGroupMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64, suffix string) (*t.Message, error) {
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
err := sqlx.GetContext(ctx, q, &row,
"SELECT id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content "+
"FROM im_group_messages WHERE topic=? AND seqid=?"+suffix,
topic, seq)
if err != nil {
return nil, err
}
msg := normalizeGroupMessage(tenantID, row.Message, row.RecalledBy)
return &msg, nil
}
func messageVersion(version int) int {
if version <= 0 {
return 1
}
return version
}
func kvMapFromJSON(value any) t.KVMap {
switch parsed := common.FromJSON(value).(type) {
case map[string]any:
return t.KVMap(parsed)
case t.KVMap:
return parsed
default:
return nil
}
}
func (a *adapter) TenantConversationSettingsGet(id t.TenantID, uid t.Uid, topic string) (*t.ConversationSettings, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantConversationSettingsGet(id, uid, topic)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(topic) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
settings, err := a.conversationSettingsByKey(ctx, uid, strings.TrimSpace(topic))
if err == sql.ErrNoRows {
settingsID, idErr := nextSnowflakeID()
if idErr != nil {
return nil, idErr
}
now := t.TimeNow()
_, err = a.db.ExecContext(ctx, `INSERT IGNORE INTO im_conversation_settings
(id,user_id,topic,pinned,muted,remark,message_retention,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`,
settingsID, decodeUID(uid), strings.TrimSpace(topic), false, false, "", "default", 1, now, now)
if err != nil {
return nil, err
}
return a.conversationSettingsByKey(ctx, uid, strings.TrimSpace(topic))
}
if err != nil {
return nil, err
}
return settings, nil
}
func (a *adapter) TenantConversationSettingsUpdate(id t.TenantID, uid t.Uid, topic string, patch t.ConversationSettingsPatch) (*t.ConversationSettings, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantConversationSettingsUpdate(id, uid, topic, patch)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(topic) == "" || patch.Version <= 0 {
return nil, t.ErrMalformed
}
if _, err := a.TenantConversationSettingsGet(id, uid, topic); err != nil {
return nil, err
}
sets := []string{}
args := []any{}
if patch.Pinned != nil {
sets = append(sets, "pinned=?")
args = append(args, *patch.Pinned)
}
if patch.Muted != nil {
sets = append(sets, "muted=?")
args = append(args, *patch.Muted)
}
if patch.Remark != nil {
sets = append(sets, "remark=?")
args = append(args, strings.TrimSpace(*patch.Remark))
}
if patch.MessageRetention != nil {
sets = append(sets, "message_retention=?")
args = append(args, strings.TrimSpace(*patch.MessageRetention))
}
if len(sets) == 0 {
return a.TenantConversationSettingsGet(id, uid, topic)
}
sets = append(sets, "version=version+1", "updated_at=CURRENT_TIMESTAMP(3)")
args = append(args, decodeUID(uid), strings.TrimSpace(topic), patch.Version)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, `UPDATE im_conversation_settings SET `+strings.Join(sets, ",")+`
WHERE user_id=? AND topic=? AND version=?`, args...)
if err != nil {
return nil, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.conversationSettingsByKey(ctx, uid, strings.TrimSpace(topic))
}
func (a *adapter) TenantTopicDraftGet(id t.TenantID, uid t.Uid, topic string) (*t.TopicDraft, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantTopicDraftGet(id, uid, topic)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(topic) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
draft, err := a.topicDraftByKey(ctx, uid, strings.TrimSpace(topic))
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return draft, err
}
func (a *adapter) TenantTopicDraftPut(id t.TenantID, uid t.Uid, topic, content string, entities []byte, version int) (*t.TopicDraft, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantTopicDraftPut(id, uid, topic, content, entities, version)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(topic) == "" || version < 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if version == 0 {
draftID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
now := t.TimeNow()
res, err := a.db.ExecContext(ctx, `INSERT INTO im_topic_drafts
(id,user_id,topic,content,entities_json,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE id=id`,
draftID, decodeUID(uid), strings.TrimSpace(topic), content, entities, 1, now, now)
if err != nil {
return nil, err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.topicDraftByKey(ctx, uid, strings.TrimSpace(topic))
}
res, err := a.db.ExecContext(ctx, `UPDATE im_topic_drafts
SET content=?,entities_json=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE user_id=? AND topic=? AND version=?`,
content, entities, decodeUID(uid), strings.TrimSpace(topic), version)
if err != nil {
return nil, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.topicDraftByKey(ctx, uid, strings.TrimSpace(topic))
}
func (a *adapter) conversationSettingsByKey(ctx context.Context, uid t.Uid, topic string) (*t.ConversationSettings, error) {
row := struct {
ID int64
UserID int64 `db:"user_id"`
Topic string `db:"topic"`
Pinned bool
Muted bool
Remark string
MessageRetention string `db:"message_retention"`
Version int `db:"version"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT id,user_id,topic,pinned,muted,remark,message_retention,version,created_at,updated_at
FROM im_conversation_settings WHERE user_id=? AND topic=?`, decodeUID(uid), topic); err != nil {
return nil, err
}
return &t.ConversationSettings{ID: row.ID, UserID: store.EncodeUid(row.UserID), UserIDRaw: row.UserID, Topic: row.Topic,
Pinned: row.Pinned, Muted: row.Muted, Remark: row.Remark, MessageRetention: row.MessageRetention,
Version: row.Version, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, nil
}
func (a *adapter) topicDraftByKey(ctx context.Context, uid t.Uid, topic string) (*t.TopicDraft, error) {
row := struct {
ID int64
UserID int64 `db:"user_id"`
Topic string `db:"topic"`
Content string
EntitiesJSON []byte `db:"entities_json"`
Version int `db:"version"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT id,user_id,topic,content,entities_json,version,created_at,updated_at
FROM im_topic_drafts WHERE user_id=? AND topic=?`, decodeUID(uid), topic); err != nil {
return nil, err
}
return &t.TopicDraft{ID: row.ID, UserID: store.EncodeUid(row.UserID), UserIDRaw: row.UserID, Topic: row.Topic,
Content: row.Content, EntitiesJSON: row.EntitiesJSON, Version: row.Version, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, nil
}
func (a *adapter) TenantFavoriteCreate(id t.TenantID, fav *t.FavoriteItem) (*t.FavoriteItem, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFavoriteCreate(id, fav)
}
}
if err := validTenant(id); err != nil || fav == nil || fav.TenantID != id || fav.UserID.IsZero() || fav.FavoriteID == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx,
`INSERT INTO im_favorites(favorite_id,user_id,item_type,source_topic,source_seq,snapshot,attachment_refs,availability,idempotency_key,collected_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
fav.FavoriteID, decodeUID(fav.UserID), fav.ItemType, fav.SourceTopic, fav.SourceSeq, common.ToJSON(fav.Snapshot), common.ToJSON(fav.AttachmentRefs), fav.Availability, fav.IdempotencyKey, fav.CollectedAt, fav.CreatedAt, fav.UpdatedAt)
if err != nil {
return nil, err
}
item, err := a.TenantFavoriteGet(id, fav.UserID, fav.FavoriteID)
if err == nil {
return item, nil
}
if err != t.ErrNotFound {
return nil, err
}
return a.tenantFavoriteFindExisting(ctx, id, fav)
}
func (a *adapter) tenantFavoriteFindExisting(ctx context.Context, id t.TenantID, fav *t.FavoriteItem) (*t.FavoriteItem, error) {
where := []string{"favorite_id=?", "(source_topic=? AND source_seq=?)"}
args := []any{fav.FavoriteID, fav.SourceTopic, fav.SourceSeq}
if fav.IdempotencyKey != "" {
where = append(where, "idempotency_key=?")
args = append(args, fav.IdempotencyKey)
}
args = append([]any{decodeUID(fav.UserID)}, args...)
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_favorites WHERE user_id=? AND ("+strings.Join(where, " OR ")+") ORDER BY id DESC LIMIT 1", args...)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, t.ErrNotFound
}
item, err := scanFavorite(rows)
if err != nil {
return nil, err
}
item.TenantID = id
return &item, nil
}
func (a *adapter) TenantFavoriteList(id t.TenantID, uid t.Uid, itemType, queryText, cursor string, limit int) ([]t.FavoriteItem, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantFavoriteList(id, uid, itemType, queryText, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
where := []string{"user_id=?"}
args := []any{decodeUID(uid)}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "id<?")
args = append(args, parsed)
}
if itemType != "" && itemType != "all" {
where = append(where, "item_type=?")
args = append(args, itemType)
}
if queryText = strings.TrimSpace(queryText); queryText != "" {
where = append(where, "CAST(snapshot AS CHAR) LIKE ?")
args = append(args, "%"+queryText+"%")
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_favorites WHERE "+strings.Join(where, " AND ")+" ORDER BY id DESC LIMIT ?", args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
items := make([]t.FavoriteItem, 0, limit)
nextCursor := ""
lastID := int64(0)
for rows.Next() {
item, err := scanFavorite(rows)
if err != nil {
return nil, "", err
}
item.TenantID = id
if len(items) == limit {
nextCursor = strconv.FormatInt(lastID, 10)
continue
}
items = append(items, item)
lastID = item.ID
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return items, nextCursor, nil
}
func (a *adapter) TenantFavoriteGet(id t.TenantID, uid t.Uid, favoriteID string) (*t.FavoriteItem, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFavoriteGet(id, uid, favoriteID)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || favoriteID == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_favorites WHERE user_id=? AND favorite_id=?", decodeUID(uid), favoriteID)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, t.ErrNotFound
}
item, err := scanFavorite(rows)
if err != nil {
return nil, err
}
item.TenantID = id
return &item, nil
}
func (a *adapter) TenantFavoriteDelete(id t.TenantID, uid t.Uid, favoriteID string) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantFavoriteDelete(id, uid, favoriteID)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || favoriteID == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, "DELETE FROM im_favorites WHERE user_id=? AND favorite_id=?", decodeUID(uid), favoriteID)
if err != nil {
return err
}
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
return t.ErrNotFound
}
return nil
}
func scanFavorite(rows *sqlx.Rows) (t.FavoriteItem, error) {
row := struct {
t.FavoriteItem
UserID int64 `db:"user_id"`
}{}
if err := rows.StructScan(&row); err != nil {
return t.FavoriteItem{}, err
}
item := row.FavoriteItem
item.UserID = store.EncodeUid(row.UserID)
item.Snapshot = common.FromJSON(item.Snapshot)
item.AttachmentRefs = common.FromJSON(item.AttachmentRefs)
return item, nil
}
func (a *adapter) TenantMessageDeleteList(id t.TenantID, topic string, del *t.DelMessage) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" || (del != nil && del.TenantID != id) {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.MessageDeleteList(topic, del)
}
if a.tenantScopedDB {
return a.MessageDeleteList(topic, del)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if err = tenantMessageDeleteList(tx, id, topic, del); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantGroupMessageDeleteList(id t.TenantID, topic string, del *t.DelMessage) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" || (del != nil && del.TenantID != id) {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageDeleteList(id, topic, del)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if err = groupMessageDeleteList(tx, topic, del); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantMessageGetDeleted(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return nil, t.ErrMalformed
}
if tenantAdp != a {
deleted, err := tenantAdp.MessageGetDeleted(topic, uid, opts)
for i := range deleted {
deleted[i].TenantID = id
}
return deleted, err
}
if a.tenantScopedDB {
deleted, err := a.MessageGetDeleted(topic, uid, opts)
for i := range deleted {
deleted[i].TenantID = id
}
return deleted, err
}
limit := a.maxResults
var lower int64 = 0
var upper int64 = 1<<31 - 1
if opts != nil {
if opts.Since > 0 {
lower = opts.Since
}
if opts.Before > 1 {
upper = opts.Before - 1
}
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx,
"SELECT tenant_id,topic,deletedfor,delid,low,hi FROM im_dellog WHERE tenant_id=? AND topic=? AND delid BETWEEN ? AND ? "+
"AND (deletedfor=0 OR deletedfor=?) ORDER BY delid LIMIT ?", id, topic, lower, upper, decodeUID(uid), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var dmsgs []t.DelMessage
var current t.DelMessage
for rows.Next() {
var row struct {
TenantID t.TenantID `db:"tenant_id"`
Topic string
Deletedfor int64
Delid int
Low int64
Hi int64
}
if err = rows.StructScan(&row); err != nil {
return nil, err
}
if row.Delid != current.DelId {
if current.DelId > 0 {
dmsgs = append(dmsgs, current)
}
current = t.DelMessage{TenantID: row.TenantID, Topic: row.Topic, DelId: row.Delid}
if row.Deletedfor > 0 {
current.DeletedFor = store.EncodeUid(row.Deletedfor).String()
}
}
if row.Hi <= row.Low+1 {
row.Hi = 0
}
current.SeqIdRanges = append(current.SeqIdRanges, t.Range{Low: row.Low, Hi: row.Hi})
}
if err = rows.Err(); err != nil {
return nil, err
}
if current.DelId > 0 {
dmsgs = append(dmsgs, current)
}
return dmsgs, nil
}
func (a *adapter) TenantDeviceUpsert(id t.TenantID, uid t.Uid, dev *t.DeviceDef) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || dev == nil || dev.TenantID != id || uid.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.DeviceUpsert(uid, dev)
}
if a.tenantScopedDB {
return a.DeviceUpsert(uid, dev)
}
hash := deviceHasher(dev.DeviceId)
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if _, err = tx.Exec("DELETE FROM im_devices WHERE tenant_id=? AND hash=?", id, hash); err != nil {
return err
}
if _, err = tx.Exec("INSERT INTO im_devices(tenant_id,userid,hash,deviceid,platform,lastseen,lang) VALUES(?,?,?,?,?,?,?)",
id, decodeUID(uid), hash, dev.DeviceId, dev.Platform, dev.LastSeen, dev.Lang); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantDeviceGetAll(id t.TenantID, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, 0, err
}
if tenantAdp != a {
devs, count, err := tenantAdp.DeviceGetAll(uids...)
for uid := range devs {
for i := range devs[uid] {
devs[uid][i].TenantID = id
}
}
return devs, count, err
}
if a.tenantScopedDB {
devs, count, err := a.DeviceGetAll(uids...)
for uid := range devs {
for i := range devs[uid] {
devs[uid][i].TenantID = id
}
}
return devs, count, err
}
var decoded []any
for _, uid := range uids {
if !uid.IsZero() {
decoded = append(decoded, decodeUID(uid))
}
}
if len(decoded) == 0 {
return map[t.Uid][]t.DeviceDef{}, 0, nil
}
q, args, _ := sqlx.In("SELECT tenant_id,userid,deviceid,platform,lastseen,lang FROM im_devices WHERE tenant_id=? AND userid IN (?)", id, decoded)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
result := make(map[t.Uid][]t.DeviceDef)
count := 0
for rows.Next() {
var row struct {
TenantID t.TenantID `db:"tenant_id"`
Userid int64
Deviceid string
Platform string
Lastseen time.Time
Lang string
}
if err = rows.StructScan(&row); err != nil {
return nil, 0, err
}
uid := store.EncodeUid(row.Userid)
result[uid] = append(result[uid], t.DeviceDef{
TenantID: row.TenantID, DeviceId: row.Deviceid, Platform: row.Platform, LastSeen: row.Lastseen, Lang: row.Lang,
})
count++
}
return result, count, rows.Err()
}
func (a *adapter) TenantDeviceDelete(id t.TenantID, uid t.Uid, deviceID string) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.DeviceDelete(uid, deviceID)
}
if a.tenantScopedDB {
return a.DeviceDelete(uid, deviceID)
}
q := "DELETE FROM im_devices WHERE tenant_id=? AND userid=?"
args := []any{id, decodeUID(uid)}
if deviceID != "" {
q += " AND hash=?"
args = append(args, deviceHasher(deviceID))
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, q, args...)
if err != nil {
return err
}
if count, _ := res.RowsAffected(); count == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantFileStartUpload(id t.TenantID, fd *t.FileDef) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || fd == nil || fd.TenantID != id {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.FileStartUpload(fd)
}
if a.tenantScopedDB {
return a.FileStartUpload(fd)
}
var user any = 0
if fd.User != "" {
user = decodeUID(t.ParseUid(fd.User))
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx,
"INSERT INTO im_fileuploads(tenant_id,id,createdat,updatedat,userid,status,mimetype,size,etag,location) VALUES(?,?,?,?,?,?,?,?,?,?)",
id, decodeUID(fd.Uid()), fd.CreatedAt, fd.UpdatedAt, user, fd.Status, fd.MimeType, fd.Size, fd.ETag, fd.Location)
return err
}
func (a *adapter) TenantFileFinishUpload(id t.TenantID, fd *t.FileDef, success bool, size int64) (*t.FileDef, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || fd == nil || fd.TenantID != id {
return nil, t.ErrMalformed
}
if tenantAdp != a {
file, err := tenantAdp.FileFinishUpload(fd, success, size)
return stampTenantFile(id, file), err
}
if a.tenantScopedDB {
file, err := a.FileFinishUpload(fd, success, size)
return stampTenantFile(id, file), err
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
now := t.TimeNow()
if success {
_, err = tx.Exec("UPDATE im_fileuploads SET updatedat=?,status=?,size=?,etag=?,location=? WHERE tenant_id=? AND id=?",
now, t.UploadCompleted, size, fd.ETag, fd.Location, id, decodeUID(fd.Uid()))
if err == nil {
fd.UpdatedAt = now
fd.Size = size
err = insertFileReviewTasks(ctx, tx, fd)
}
fd.Status = t.UploadCompleted
} else {
_, err = tx.Exec("DELETE FROM im_fileuploads WHERE tenant_id=? AND id=?", id, decodeUID(fd.Uid()))
fd.Status = t.UploadFailed
fd.Size = 0
}
if err != nil {
return nil, err
}
fd.UpdatedAt = now
return fd, tx.Commit()
}
func (a *adapter) TenantFileGet(id t.TenantID, fid string) (*t.FileDef, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
file, err := tenantAdp.FileGet(fid)
return stampTenantFile(id, file), err
}
if a.tenantScopedDB {
file, err := a.FileGet(fid)
return stampTenantFile(id, file), err
}
fileID := parseFileUID(fid)
if fileID.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var fd t.FileDef
err = a.db.GetContext(ctx, &fd,
"SELECT tenant_id,id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+
"FROM im_fileuploads WHERE tenant_id=? AND id=?", id, decodeUID(fileID))
if err == sql.ErrNoRows {
storageName := fileID.String32()
err = a.db.GetContext(ctx, &fd,
"SELECT tenant_id,id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+
"FROM im_fileuploads WHERE tenant_id=? AND (location=? OR location LIKE ?) ORDER BY createdat DESC LIMIT 1",
id, storageName, "%/"+storageName)
}
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
fd.Id = common.EncodeUidString(fd.Id).String()
fd.User = common.EncodeUidString(fd.User).String()
return &fd, nil
}
func (a *adapter) TenantFileDeleteUnused(id t.TenantID, olderThan time.Time, limit int) ([]string, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.FileDeleteUnused(olderThan, limit)
}
if a.tenantScopedDB {
return a.FileDeleteUnused(olderThan, limit)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
query := "SELECT fu.id,fu.location FROM im_fileuploads AS fu LEFT JOIN im_filemsglinks AS fml ON fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id WHERE fu.tenant_id=? AND fml.id IS NULL"
args := []any{id}
if !olderThan.IsZero() {
query += " AND fu.updatedat<?"
args = append(args, olderThan)
}
if limit > 0 {
query += " LIMIT ?"
args = append(args, limit)
}
rows, err := tx.Query(query, args...)
if err != nil {
return nil, err
}
var locations []string
var ids []any
for rows.Next() {
var fileID int64
var location string
if err = rows.Scan(&fileID, &location); err != nil {
rows.Close()
return nil, err
}
if location != "" {
locations = append(locations, location)
}
ids = append(ids, fileID)
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
if len(ids) > 0 {
q, args, _ := sqlx.In("DELETE FROM im_fileuploads WHERE tenant_id=? AND id IN (?)", id, ids)
if _, err = tx.Exec(tx.Rebind(q), args...); err != nil {
return nil, err
}
}
return locations, tx.Commit()
}
func (a *adapter) TenantFileLinkAttachments(id t.TenantID, topic string, userID, msgID t.Uid, fids []string) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || len(fids) == 0 || (topic == "" && msgID.IsZero() && userID.IsZero()) {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.FileLinkAttachments(topic, userID, msgID, fids)
}
if a.tenantScopedDB {
return a.FileLinkAttachments(topic, userID, msgID, fids)
}
now := t.TimeNow()
var linkID any
var linkBy string
if !msgID.IsZero() {
linkBy = "msgid"
linkID = int64(msgID)
} else if topic != "" {
linkBy = "topic"
linkID = topic
fids = fids[:1]
} else {
linkBy = "userid"
linkID = decodeUID(userID)
fids = fids[:1]
}
var args []any
var dids []any
for _, fid := range fids {
fileID := t.ParseUid(fid)
if fileID.IsZero() {
return t.ErrMalformed
}
decoded := decodeUID(fileID)
dids = append(dids, decoded)
args = append(args, id, now, decoded, linkID)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
var existing int
query, queryArgs, err := sqlx.In("SELECT COUNT(*) FROM im_fileuploads WHERE tenant_id=? AND id IN (?) AND status=?", id, dids, t.UploadCompleted)
if err != nil {
return err
}
if err = a.db.GetContext(ctx, &existing, a.db.Rebind(query), queryArgs...); err != nil {
return err
}
if existing != len(dids) {
return t.ErrNotFound
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if msgID.IsZero() {
if _, err = tx.Exec("DELETE FROM im_filemsglinks WHERE tenant_id=? AND "+linkBy+"=?", id, linkID); err != nil {
return err
}
}
sqlq := "INSERT INTO im_filemsglinks(tenant_id,createdat,fileid," + linkBy + ") VALUES (?,?,?,?)"
_, err = tx.Exec(sqlq+strings.Repeat(",(?,?,?,?)", len(fids)-1), args...)
if err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantFileUserHasAccess(id t.TenantID, fid string, uid t.Uid) (bool, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return false, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.FileUserHasAccess(fid, uid)
}
if a.tenantScopedDB {
return a.FileUserHasAccess(fid, uid)
}
fileID := parseFileUID(fid)
if fileID.IsZero() {
return false, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var allowed int
err = a.db.GetContext(ctx, &allowed, `
SELECT COUNT(*) FROM im_fileuploads AS fu
WHERE fu.tenant_id=? AND (fu.id=? OR fu.location=? OR fu.location LIKE ?) AND fu.status=? AND (
fu.userid=?
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.userid=?
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_friendships AS fs
ON fs.user_id=? AND fs.peer_id=fml.userid AND fs.status=?
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.userid IS NOT NULL
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_subscriptions AS s
ON s.tenant_id=fml.tenant_id AND s.topic=fml.topic AND s.userid=?
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.topic IS NOT NULL
AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_group_members AS gm
ON gm.topic=fml.topic AND gm.user_id=? AND gm.member_status=?
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.topic IS NOT NULL
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_messages AS m
ON m.tenant_id=fml.tenant_id AND m.id=fml.msgid
INNER JOIN im_subscriptions AS s
ON s.tenant_id=m.tenant_id AND s.topic=m.topic AND s.userid=?
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.msgid IS NOT NULL
AND m.deletedat IS NULL AND m.recalled_at IS NULL
AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0
AND NOT EXISTS (
SELECT 1 FROM im_dellog AS d
WHERE d.tenant_id=m.tenant_id AND d.topic=m.topic AND d.deletedfor=?
AND d.low<=m.seqid AND (d.hi=0 OR d.hi>m.seqid)
)
)
OR EXISTS (
SELECT 1 FROM im_group_files AS gf
INNER JOIN im_subscriptions AS s
ON s.tenant_id=fu.tenant_id AND s.topic=gf.topic AND s.userid=?
WHERE (gf.file_id=? OR gf.file_id LIKE CONCAT(?,'.%')) AND gf.status='active'
AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0
)
) LIMIT 1`,
id, decodeUID(fileID), fileID.String32(), "%/"+fileID.String32(), t.UploadCompleted, decodeUID(uid), decodeUID(uid), decodeUID(uid), t.RelationshipFriend,
decodeUID(uid), decodeUID(uid), t.GroupMemberStatusActive, decodeUID(uid), decodeUID(uid),
decodeUID(uid), fid, fid)
if err != nil {
return false, err
}
return allowed > 0, nil
}
func (a *adapter) TenantPCacheGet(id t.TenantID, key string) (string, error) {
if err := validTenant(id); err != nil {
return "", err
}
scoped, err := tenantCacheKey(id, key)
if err != nil {
return "", err
}
return a.PCacheGet(scoped)
}
func (a *adapter) TenantPCacheUpsert(id t.TenantID, key, value string, fail bool) error {
if err := validTenant(id); err != nil {
return err
}
scoped, err := tenantCacheKey(id, key)
if err != nil {
return err
}
return a.PCacheUpsert(scoped, value, fail)
}
func (a *adapter) TenantPCacheDelete(id t.TenantID, key string) error {
if err := validTenant(id); err != nil {
return err
}
scoped, err := tenantCacheKey(id, key)
if err != nil {
return err
}
return a.PCacheDelete(scoped)
}
func (a *adapter) TenantPCacheExpire(id t.TenantID, prefix string, before time.Time) error {
if err := validTenant(id); err != nil {
return err
}
scoped, err := tenantCacheKey(id, prefix)
if err != nil {
return err
}
return a.PCacheExpire(scoped, before)
}
func (a *adapter) TenantVerificationCreate(id t.TenantID, vc *t.VerificationCode) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantVerificationCreate(id, vc)
}
}
if err := validTenant(id); err != nil || vc == nil || vc.TenantID != id ||
vc.ChallengeID == "" || vc.Scene == "" || vc.PhoneHash == "" || vc.CodeHash == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
now := t.TimeNow()
_, err = tx.ExecContext(ctx,
`UPDATE im_verification_codes
SET state=?,updated_at=?
WHERE scene=? AND phone_hash=? AND state=?`,
t.VerificationStateExpired, now, vc.Scene, vc.PhoneHash, t.VerificationStatePending)
if err != nil {
return err
}
vc.CreatedAt = now
vc.UpdatedAt = now
_, err = tx.ExecContext(ctx,
`INSERT INTO im_verification_codes
(challenge_id,scene,action,phone_hash,phone_mask,target_user,code_hash,state,attempts,max_attempts,
expires_at,resend_after,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
vc.ChallengeID, vc.Scene, vc.Action, vc.PhoneHash, vc.PhoneMask, nullableUID(vc.TargetUser), vc.CodeHash, vc.State, vc.Attempts,
vc.MaxAttempts, vc.ExpiresAt, vc.ResendAfter, vc.CreatedAt, vc.UpdatedAt)
if isDupe(err) {
return t.ErrDuplicate
}
if err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantVerificationGet(id t.TenantID, challengeID string) (*t.VerificationCode, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantVerificationGet(id, challengeID)
}
}
if err := validTenant(id); err != nil || challengeID == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var row verificationCodeRow
err := a.db.GetContext(ctx, &row,
`SELECT id,challenge_id,scene,action,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash,
state,attempts,max_attempts,expires_at,resend_after,verified_at,consumed_at,created_at,updated_at
FROM im_verification_codes
WHERE challenge_id=? LIMIT 1`, challengeID)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return row.toVerificationCode(id), nil
}
func (a *adapter) TenantVerificationGetActive(id t.TenantID, scene, phoneHash string, now time.Time) (*t.VerificationCode, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantVerificationGetActive(id, scene, phoneHash, now)
}
}
if err := validTenant(id); err != nil || scene == "" || phoneHash == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var row verificationCodeRow
err := a.db.GetContext(ctx, &row,
`SELECT id,challenge_id,scene,action,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash,
state,attempts,max_attempts,expires_at,resend_after,verified_at,consumed_at,created_at,updated_at
FROM im_verification_codes
WHERE scene=? AND phone_hash=? AND state=? AND expires_at>?
ORDER BY created_at DESC LIMIT 1`, scene, phoneHash, t.VerificationStatePending, now)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return row.toVerificationCode(id), nil
}
type verificationCodeRow struct {
ID int64 `db:"id"`
ChallengeID string `db:"challenge_id"`
Scene string `db:"scene"`
Action string `db:"action"`
PhoneHash string `db:"phone_hash"`
PhoneMask string `db:"phone_mask"`
TargetUser int64 `db:"target_user"`
CodeHash string `db:"code_hash"`
VerificationTokenHash *string `db:"verification_token_hash"`
State t.VerificationState `db:"state"`
Attempts int `db:"attempts"`
MaxAttempts int `db:"max_attempts"`
ExpiresAt time.Time `db:"expires_at"`
ResendAfter time.Time `db:"resend_after"`
VerifiedAt *time.Time `db:"verified_at"`
ConsumedAt *time.Time `db:"consumed_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
func (r verificationCodeRow) toVerificationCode(tenantID t.TenantID) *t.VerificationCode {
vc := &t.VerificationCode{
ID: r.ID,
TenantID: tenantID,
ChallengeID: r.ChallengeID,
Scene: r.Scene,
Action: r.Action,
PhoneHash: r.PhoneHash,
PhoneMask: r.PhoneMask,
CodeHash: r.CodeHash,
VerificationTokenHash: r.VerificationTokenHash,
State: r.State,
Attempts: r.Attempts,
MaxAttempts: r.MaxAttempts,
ExpiresAt: r.ExpiresAt,
ResendAfter: r.ResendAfter,
VerifiedAt: r.VerifiedAt,
ConsumedAt: r.ConsumedAt,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
if r.TargetUser > 0 {
vc.TargetUser = store.EncodeUid(r.TargetUser)
}
return vc
}
func (a *adapter) TenantVerificationFail(id t.TenantID, challengeID string, state t.VerificationState) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantVerificationFail(id, challengeID, state)
}
}
if err := validTenant(id); err != nil || challengeID == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
now := t.TimeNow()
_, err := a.db.ExecContext(ctx,
`UPDATE im_verification_codes
SET attempts=attempts+1,state=?,updated_at=?
WHERE challenge_id=?`,
state, now, challengeID)
return err
}
func (a *adapter) TenantVerificationMarkVerified(id t.TenantID, challengeID, tokenHash string, verifiedAt time.Time) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantVerificationMarkVerified(id, challengeID, tokenHash, verifiedAt)
}
}
if err := validTenant(id); err != nil || challengeID == "" || tokenHash == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx,
`UPDATE im_verification_codes
SET state=?,verification_token_hash=?,verified_at=?,updated_at=?
WHERE challenge_id=? AND state=?`,
t.VerificationStateVerified, tokenHash, verifiedAt, verifiedAt, challengeID, t.VerificationStatePending)
return err
}
func (a *adapter) TenantVerificationConsume(id t.TenantID, challengeID, tokenHash string, consumedAt time.Time) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantVerificationConsume(id, challengeID, tokenHash, consumedAt)
}
}
if err := validTenant(id); err != nil || challengeID == "" || tokenHash == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx,
`UPDATE im_verification_codes
SET state=?,consumed_at=?,updated_at=?
WHERE challenge_id=? AND verification_token_hash=? AND state=? AND expires_at>?`,
t.VerificationStateConsumed, consumedAt, consumedAt, challengeID, tokenHash,
t.VerificationStateVerified, consumedAt)
if err != nil {
return err
}
if rows, _ := res.RowsAffected(); rows == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantUserSecurityGetAuthVersion(id t.TenantID, uid t.Uid) (int, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return 0, err
}
if tenantAdp != a {
return tenantAdp.TenantUserSecurityGetAuthVersion(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return 0, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
decoded := decodeUID(uid)
now := t.TimeNow()
securityID, err := nextSnowflakeID()
if err != nil {
return 0, err
}
_, err = a.db.ExecContext(ctx,
`INSERT INTO im_user_security(id,user_id,auth_version,created_at,updated_at)
VALUES(?,?,?,?,?)
ON DUPLICATE KEY UPDATE auth_version=auth_version`,
securityID, decoded, 1, now, now)
if err != nil {
return 0, err
}
var version int
err = a.db.GetContext(ctx, &version,
`SELECT auth_version FROM im_user_security WHERE user_id=?`,
decoded)
if err == sql.ErrNoRows {
return 0, t.ErrNotFound
}
if err != nil {
return 0, err
}
return version, nil
}
func (a *adapter) TenantUserSecurityBumpAuthVersion(id t.TenantID, uid t.Uid, updatedAt time.Time) (int, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return 0, err
}
if tenantAdp != a {
return tenantAdp.TenantUserSecurityBumpAuthVersion(id, uid, updatedAt)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return 0, t.ErrMalformed
}
if updatedAt.IsZero() {
updatedAt = t.TimeNow()
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return 0, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
decoded := decodeUID(uid)
securityID, err := nextSnowflakeID()
if err != nil {
return 0, err
}
if _, err = tx.ExecContext(ctx,
`INSERT INTO im_user_security(id,user_id,auth_version,password_updated_at,created_at,updated_at)
VALUES(?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE
auth_version=auth_version+1,
password_updated_at=VALUES(password_updated_at),
updated_at=VALUES(updated_at)`,
securityID, decoded, 2, updatedAt, updatedAt, updatedAt); err != nil {
return 0, err
}
var version int
if err = tx.GetContext(ctx, &version,
`SELECT auth_version FROM im_user_security WHERE user_id=?`,
decoded); err != nil {
return 0, err
}
if err = tx.Commit(); err != nil {
return 0, err
}
return version, nil
}
func (a *adapter) TenantRealNameGet(id t.TenantID, uid t.Uid) (*t.RealNameVerification, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantRealNameGet(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var rv t.RealNameVerification
err := a.db.GetContext(ctx, &rv,
`SELECT id,user_id,submission_id,status,name_cipher,id_type,id_number_cipher,id_number_hash,
masked_name,masked_id_number,review_reason,verified_at,created_at,updated_at
FROM im_realname_verifications
WHERE user_id=?
ORDER BY created_at DESC,id DESC LIMIT 1`,
decodeUID(uid))
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
rv.TenantID = id
return &rv, nil
}
func (a *adapter) TenantRealNameCreate(id t.TenantID, rv *t.RealNameVerification) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantRealNameCreate(id, rv)
}
}
if err := validTenant(id); err != nil || rv == nil || rv.TenantID != id || rv.UserID.IsZero() ||
rv.SubmissionID == "" || rv.Status == "" || rv.NameCipher == "" || rv.IDType == "" ||
rv.IDNumberCipher == "" || rv.IDNumberHash == "" || rv.MaskedName == "" || rv.MaskedIDNumber == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
now := t.TimeNow()
rv.CreatedAt = now
rv.UpdatedAt = now
_, err := a.db.ExecContext(ctx,
`INSERT INTO im_realname_verifications
(user_id,submission_id,status,name_cipher,id_type,id_number_cipher,id_number_hash,
masked_name,masked_id_number,review_reason,verified_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
decodeUID(rv.UserID), rv.SubmissionID, rv.Status, rv.NameCipher, rv.IDType, rv.IDNumberCipher, rv.IDNumberHash,
rv.MaskedName, rv.MaskedIDNumber, rv.ReviewReason, rv.VerifiedAt, rv.CreatedAt, rv.UpdatedAt)
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
func (a *adapter) TenantRealNameAppealCreate(id t.TenantID, appeal *t.RealNameAppeal) (*t.RealNameAppeal, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantRealNameAppealCreate(id, appeal)
}
}
if err := validTenant(id); err != nil || appeal == nil || appeal.TenantID != id || appeal.UserID.IsZero() ||
strings.TrimSpace(appeal.AppealID) == "" || strings.TrimSpace(appeal.Reason) == "" {
return nil, t.ErrMalformed
}
appealID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
now := t.TimeNow()
if appeal.Status == "" {
appeal.Status = "pending"
}
appeal.ID = appealID
appeal.CreatedAt = now
appeal.UpdatedAt = now
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx, `INSERT INTO im_realname_appeals
(id,appeal_id,user_id,reason,attachments_json,status,review_comment,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
appeal.ID, appeal.AppealID, decodeUID(appeal.UserID), appeal.Reason, appeal.AttachmentsJSON,
appeal.Status, appeal.ReviewComment, appeal.CreatedAt, appeal.UpdatedAt)
if isDupe(err) {
return nil, t.ErrDuplicate
}
if err != nil {
return nil, err
}
return appeal, nil
}
func (a *adapter) TenantActionTokenCreate(id t.TenantID, token *t.ActionToken) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantActionTokenCreate(id, token)
}
}
if err := validTenant(id); err != nil || token == nil || token.TenantID != id || token.UserID.IsZero() ||
token.TokenID == "" || token.Action == "" || token.TokenHash == "" || token.ExpiresAt.IsZero() {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
now := t.TimeNow()
token.CreatedAt = now
token.UpdatedAt = now
_, err := a.db.ExecContext(ctx,
`INSERT INTO im_action_tokens
(user_id,token_id,action,target,token_hash,state,expires_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
decodeUID(token.UserID), token.TokenID, token.Action, token.Target, token.TokenHash, token.State,
token.ExpiresAt, token.CreatedAt, token.UpdatedAt)
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
func (a *adapter) TenantActionTokenConsume(id t.TenantID, uid t.Uid, token, action, target string, now time.Time) (*t.ActionToken, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantActionTokenConsume(id, uid, token, action, target, now)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || token == "" || action == "" {
return nil, t.ErrMalformed
}
if now.IsZero() {
now = t.TimeNow()
}
tokenID, secret, ok := strings.Cut(token, ".")
if !ok || tokenID == "" || secret == "" {
return nil, t.ErrMalformed
}
tokenHash := securityTokenHash(id, uid, action, target, tokenID, secret)
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
var at t.ActionToken
err = tx.GetContext(ctx, &at,
`SELECT id,user_id,token_id,action,target,token_hash,state,expires_at,consumed_at,created_at,updated_at
FROM im_action_tokens
WHERE user_id=? AND token_id=? AND action=? AND target=? LIMIT 1`,
decodeUID(uid), tokenID, action, target)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
if at.State != 0 || at.TokenHash != tokenHash || !now.Before(at.ExpiresAt) {
return nil, t.ErrNotFound
}
res, err := tx.ExecContext(ctx,
`UPDATE im_action_tokens SET state=1,consumed_at=?,updated_at=?
WHERE user_id=? AND token_id=? AND action=? AND target=? AND state=0 AND expires_at>?`,
now, now, decodeUID(uid), tokenID, action, target, now)
if err != nil {
return nil, err
}
if rows, _ := res.RowsAffected(); rows == 0 {
return nil, t.ErrNotFound
}
if err = tx.Commit(); err != nil {
return nil, err
}
at.TenantID = id
return &at, nil
}
func (a *adapter) TenantIndependentPasswordGet(id t.TenantID, uid t.Uid, purpose string) (*t.IndependentPassword, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantIndependentPasswordGet(id, uid, purpose)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || purpose == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var pass t.IndependentPassword
err := a.db.GetContext(ctx, &pass,
`SELECT user_id,purpose,password_hash,failed_attempts,locked_until,created_at,updated_at
FROM im_youth_mode_settings
WHERE user_id=? AND purpose=?`,
decodeUID(uid), purpose)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
pass.TenantID = id
return &pass, nil
}
func (a *adapter) TenantIndependentPasswordUpsert(id t.TenantID, pass *t.IndependentPassword) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantIndependentPasswordUpsert(id, pass)
}
}
if err := validTenant(id); err != nil || pass == nil || pass.TenantID != id || pass.UserID.IsZero() ||
pass.Purpose == "" || len(pass.PasswordHash) == 0 {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
now := t.TimeNow()
pass.CreatedAt = now
pass.UpdatedAt = now
settingsID, err := nextSnowflakeID()
if err != nil {
return err
}
_, err = a.db.ExecContext(ctx,
`INSERT INTO im_youth_mode_settings
(id,user_id,purpose,password_hash,failed_attempts,locked_until,created_at,updated_at)
VALUES(?,?,?,?,0,NULL,?,?)
ON DUPLICATE KEY UPDATE password_hash=VALUES(password_hash),failed_attempts=0,locked_until=NULL,updated_at=VALUES(updated_at)`,
settingsID, decodeUID(pass.UserID), pass.Purpose, pass.PasswordHash, pass.CreatedAt, pass.UpdatedAt)
return err
}
func (a *adapter) TenantIndependentPasswordUpdateAttempts(id t.TenantID, uid t.Uid, purpose string, attempts int, lockedUntil *time.Time, updatedAt time.Time) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantIndependentPasswordUpdateAttempts(id, uid, purpose, attempts, lockedUntil, updatedAt)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || purpose == "" || attempts < 0 {
return t.ErrMalformed
}
if updatedAt.IsZero() {
updatedAt = t.TimeNow()
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx,
`UPDATE im_youth_mode_settings SET failed_attempts=?,locked_until=?,updated_at=?
WHERE user_id=? AND purpose=?`,
attempts, lockedUntil, updatedAt, decodeUID(uid), purpose)
return err
}
func (a *adapter) TenantYouthModeGet(id t.TenantID, uid t.Uid) (*t.YouthModeSettings, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantYouthModeGet(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
settings, err := a.youthModeByUID(ctx, uid)
if err == sql.ErrNoRows {
return defaultYouthModeSettings(uid), nil
}
return settings, err
}
func (a *adapter) TenantYouthModeSet(id t.TenantID, uid t.Uid, enabled bool, policyVersion int, updatedAt time.Time) (*t.YouthModeSettings, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantYouthModeSet(id, uid, enabled, policyVersion, updatedAt)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || policyVersion <= 0 {
return nil, t.ErrMalformed
}
if updatedAt.IsZero() {
updatedAt = t.TimeNow()
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var enabledAt *time.Time
if enabled {
enabledAt = &updatedAt
}
res, err := a.db.ExecContext(ctx,
`UPDATE im_youth_mode_settings
SET enabled=?,policy_version=?,enabled_at=?,restrictions=COALESCE(restrictions,'["night_quiet","content_filter","payment_guard"]'),updated_at=?
WHERE user_id=? AND purpose=? AND LENGTH(password_hash)>0`,
enabled, policyVersion, enabledAt, updatedAt, decodeUID(uid), "youth_mode")
if err != nil {
return nil, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil, t.ErrMalformed
}
return a.youthModeByUID(ctx, uid)
}
func (a *adapter) youthModeByUID(ctx context.Context, uid t.Uid) (*t.YouthModeSettings, error) {
row := struct {
UserID int64 `db:"user_id"`
PasswordHash []byte `db:"password_hash"`
Enabled bool `db:"enabled"`
PolicyVersion int `db:"policy_version"`
Restrictions sql.NullString `db:"restrictions"`
EnabledAt *time.Time `db:"enabled_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row,
`SELECT user_id,password_hash,enabled,policy_version,restrictions,enabled_at,created_at,updated_at
FROM im_youth_mode_settings WHERE user_id=? AND purpose=?`, decodeUID(uid), "youth_mode"); err != nil {
return nil, err
}
settings := &t.YouthModeSettings{
UserID: store.EncodeUid(row.UserID),
Enabled: row.Enabled,
PolicyVersion: row.PolicyVersion,
Restrictions: []string{"night_quiet", "content_filter", "payment_guard"},
PasswordSet: len(row.PasswordHash) > 0,
EnabledAt: row.EnabledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if row.Restrictions.Valid && row.Restrictions.String != "" {
_ = json.Unmarshal([]byte(row.Restrictions.String), &settings.Restrictions)
}
settings.CanEnable = settings.PasswordSet && !settings.Enabled
return settings, nil
}
func defaultYouthModeSettings(uid t.Uid) *t.YouthModeSettings {
now := t.TimeNow()
return &t.YouthModeSettings{
UserID: uid,
Enabled: false,
PolicyVersion: 1,
Restrictions: []string{"night_quiet", "content_filter", "payment_guard"},
PasswordSet: false,
CanEnable: false,
CreatedAt: now,
UpdatedAt: now,
}
}
func (a *adapter) TenantSystemNotificationCreate(id t.TenantID, notification *t.SystemNotification) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantSystemNotificationCreate(id, notification)
}
}
if err := validTenant(id); err != nil || notification == nil || notification.TenantID != id ||
notification.UserID.IsZero() || notification.EventID == "" || notification.EventType == "" ||
notification.Title == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
now := t.TimeNow()
notification.CreatedAt = now
notification.UpdatedAt = now
_, err := a.db.ExecContext(ctx,
`INSERT INTO im_system_notifications
(user_id,event_id,event_type,title,summary,action,read_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE
event_type=VALUES(event_type),
title=VALUES(title),
summary=VALUES(summary),
action=VALUES(action),
updated_at=VALUES(updated_at)`,
decodeUID(notification.UserID), notification.EventID, notification.EventType, notification.Title,
notification.Summary, common.ToJSON(notification.Action), notification.ReadAt, notification.CreatedAt, notification.UpdatedAt)
return err
}
func (a *adapter) TenantSystemNotificationList(id t.TenantID, uid t.Uid, cursor string, limit int) ([]t.SystemNotification, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantSystemNotificationList(id, uid, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 20
}
lastID := int64(0)
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed < 0 {
return nil, "", t.ErrMalformed
}
lastID = parsed
}
query := `SELECT id,user_id,event_id,event_type,title,summary,action,read_at,created_at,updated_at
FROM im_system_notifications
WHERE user_id=?`
args := []any{decodeUID(uid)}
if lastID > 0 {
query += " AND id<?"
args = append(args, lastID)
}
query += " ORDER BY id DESC LIMIT ?"
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
notifications := make([]t.SystemNotification, 0, limit)
nextCursor := ""
for rows.Next() {
var notification t.SystemNotification
if err = rows.StructScan(&notification); err != nil {
return nil, "", err
}
if action, ok := common.FromJSON(notification.Action).(map[string]any); ok {
notification.Action = t.KVMap(action)
}
notification.TenantID = id
if len(notifications) == limit {
nextCursor = strconv.FormatInt(notification.ID, 10)
continue
}
notifications = append(notifications, notification)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return notifications, nextCursor, nil
}
func (a *adapter) TenantSystemNotificationUnreadCount(id t.TenantID, uid t.Uid) (int, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return 0, err
}
if tenantAdp != a {
return tenantAdp.TenantSystemNotificationUnreadCount(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return 0, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var count int
if err := a.db.GetContext(ctx, &count,
`SELECT COUNT(*) FROM im_system_notifications WHERE user_id=? AND read_at IS NULL`,
decodeUID(uid)); err != nil {
return 0, err
}
return count, nil
}
func (a *adapter) TenantSystemNotificationUnreadCountByEventPrefix(id t.TenantID, uid t.Uid, eventPrefix string) (int, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return 0, err
}
if tenantAdp != a {
return tenantAdp.TenantSystemNotificationUnreadCountByEventPrefix(id, uid, eventPrefix)
}
}
eventPrefix = strings.TrimSpace(eventPrefix)
if err := validTenant(id); err != nil || uid.IsZero() || eventPrefix == "" {
return 0, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var count int
if err := a.db.GetContext(ctx, &count,
`SELECT COUNT(*) FROM im_system_notifications WHERE user_id=? AND read_at IS NULL AND event_type LIKE ?`,
decodeUID(uid), eventPrefix+"%"); err != nil {
return 0, err
}
return count, nil
}
func (a *adapter) TenantSystemNotificationMarkRead(id t.TenantID, uid t.Uid, eventID string, readAt time.Time) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantSystemNotificationMarkRead(id, uid, eventID, readAt)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || eventID == "" {
return t.ErrMalformed
}
if readAt.IsZero() {
readAt = t.TimeNow()
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx,
`UPDATE im_system_notifications
SET read_at=COALESCE(read_at,?),updated_at=?
WHERE user_id=? AND event_id=?`,
readAt, readAt, decodeUID(uid), eventID)
if err != nil {
return err
}
if rows, _ := res.RowsAffected(); rows == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantFriendRequestCreate(id t.TenantID, req *t.FriendRequest) (*t.FriendRequest, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFriendRequestCreate(id, req)
}
}
if err := validTenant(id); err != nil || req == nil || req.TenantID != id || req.ApplicantID.IsZero() || req.TargetID.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx,
"INSERT INTO im_friend_requests(request_id,applicant_id,target_id,message,source,source_topic,status,idempotency_key,expires_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
req.RequestID, decodeUID(req.ApplicantID), decodeUID(req.TargetID), req.Message, req.Source, req.SourceTopic, req.Status, req.IdempotencyKey, req.ExpiresAt, req.CreatedAt, req.UpdatedAt)
if err != nil {
if isDupe(err) && req.IdempotencyKey != "" {
tx.Rollback()
return a.friendRequestByIdempotency(ctx, id, req.ApplicantID, req.IdempotencyKey)
}
return nil, err
}
eventType := "friend.request.created"
eventID := t.StableEventID("relation", req.RequestID, eventType, req.TargetID.UserId())
if err = insertSystemNotificationTx(tx, req.TargetID, t.StableEventID(eventID, "notification"), eventType,
"新的好友申请", req.Message, map[string]any{"type": "friend_request", "request_id": req.RequestID, "user_id": req.ApplicantID.UserId()}); err != nil {
return nil, err
}
if err = insertBusinessEventTx(tx, eventID, "user", req.TargetID.UserId(), req.ApplicantID, eventType,
map[string]any{"request_id": req.RequestID, "user_id": req.ApplicantID.UserId()}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.friendRequestByRequestID(ctx, id, req.RequestID)
}
func (a *adapter) TenantFriendRequestList(id t.TenantID, uid t.Uid, direction, status, cursor string, limit int) ([]t.FriendRequestListItem, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantFriendRequestList(id, uid, direction, status, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 20
}
where := []string{}
args := []any{}
decodedUID := decodeUID(uid)
switch direction {
case "", "inbound":
where = append(where, "fr.target_id=?")
args = append(args, decodedUID)
case "outbound":
where = append(where, "fr.applicant_id=?")
args = append(args, decodedUID)
case "all":
where = append(where, "(fr.applicant_id=? OR fr.target_id=?)")
args = append(args, decodedUID, decodedUID)
default:
return nil, "", t.ErrMalformed
}
if status != "" && status != "all" {
where = append(where, "fr.status=?")
args = append(args, status)
}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "fr.id<?")
args = append(args, parsed)
}
args = append(args, limit+1)
query := `SELECT fr.*,u.public,u.trusted
FROM im_friend_requests AS fr
JOIN im_users AS u ON u.id=IF(fr.applicant_id=?,fr.target_id,fr.applicant_id)
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY fr.id DESC LIMIT ?`
args = append([]any{decodedUID}, args...)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
items := make([]t.FriendRequestListItem, 0, limit)
nextCursor := ""
for rows.Next() {
row := struct {
ID int64
RequestID string `db:"request_id"`
ApplicantID int64 `db:"applicant_id"`
TargetID int64 `db:"target_id"`
Message string
Source string
SourceTopic string `db:"source_topic"`
Status string
IdempotencyKey string `db:"idempotency_key"`
ExpiresAt time.Time `db:"expires_at"`
HandledAt *time.Time `db:"handled_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
Public any
Trusted any
}{}
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
if len(items) == limit {
nextCursor = strconv.FormatInt(row.ID, 10)
continue
}
applicant := store.EncodeUid(row.ApplicantID)
target := store.EncodeUid(row.TargetID)
peerID := target
direction := "outbound"
if target == uid {
peerID = applicant
direction = "inbound"
}
items = append(items, t.FriendRequestListItem{
Request: t.FriendRequest{
ID: row.ID,
TenantID: id,
RequestID: row.RequestID,
ApplicantID: applicant,
TargetID: target,
Message: row.Message,
Source: row.Source,
SourceTopic: row.SourceTopic,
Status: row.Status,
IdempotencyKey: row.IdempotencyKey,
ExpiresAt: row.ExpiresAt,
HandledAt: row.HandledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
},
Peer: &t.User{
ObjHeader: t.ObjHeader{Id: peerID.String()},
TenantID: id,
Public: common.FromJSON(row.Public),
Trusted: common.FromJSON(row.Trusted),
},
Direction: direction,
})
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return items, nextCursor, nil
}
func (a *adapter) TenantFriendRequestPendingCount(id t.TenantID, uid t.Uid) (int, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return 0, err
}
if tenantAdp != a {
return tenantAdp.TenantFriendRequestPendingCount(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return 0, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var count int
if err := a.db.GetContext(ctx, &count,
`SELECT COUNT(*) FROM im_friend_requests WHERE target_id=? AND status=? AND expires_at>?`,
decodeUID(uid), t.FriendRequestPending, t.TimeNow()); err != nil {
return 0, err
}
return count, nil
}
func (a *adapter) TenantFriendRequestLatestBetween(id t.TenantID, uid1, uid2 t.Uid) (*t.FriendRequest, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFriendRequestLatestBetween(id, uid1, uid2)
}
}
if err := validTenant(id); err != nil || uid1.IsZero() || uid2.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
return a.friendRequestByQuery(ctx,
id,
"SELECT * FROM im_friend_requests WHERE ((applicant_id=? AND target_id=?) OR (applicant_id=? AND target_id=?)) ORDER BY created_at DESC,id DESC LIMIT 1",
decodeUID(uid1), decodeUID(uid2), decodeUID(uid2), decodeUID(uid1))
}
func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, actor t.Uid, handledAt time.Time) (*t.FriendRequest, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFriendRequestAccept(id, requestID, actor, handledAt)
}
}
if err := validTenant(id); err != nil || requestID == "" || actor.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
row := struct {
ID int64
RequestID string `db:"request_id"`
ApplicantID int64 `db:"applicant_id"`
TargetID int64 `db:"target_id"`
Message string
Source string
SourceTopic string `db:"source_topic"`
Status string
IdempotencyKey string `db:"idempotency_key"`
ExpiresAt time.Time `db:"expires_at"`
HandledAt *time.Time `db:"handled_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
err = tx.GetContext(ctx, &row, "SELECT * FROM im_friend_requests WHERE request_id=? FOR UPDATE", requestID)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
if store.EncodeUid(row.TargetID) != actor {
return nil, t.ErrPermissionDenied
}
if row.Status != t.FriendRequestPending && row.Status != t.FriendRequestAccepted {
return &t.FriendRequest{
ID: row.ID,
TenantID: id,
RequestID: row.RequestID,
ApplicantID: store.EncodeUid(row.ApplicantID),
TargetID: store.EncodeUid(row.TargetID),
Message: row.Message,
Source: row.Source,
SourceTopic: row.SourceTopic,
Status: row.Status,
IdempotencyKey: row.IdempotencyKey,
ExpiresAt: row.ExpiresAt,
HandledAt: row.HandledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}, nil
}
applicant := store.EncodeUid(row.ApplicantID)
target := store.EncodeUid(row.TargetID)
topic := applicant.P2PName(target)
if topic == "" {
return nil, t.ErrMalformed
}
if row.Status == t.FriendRequestPending {
if _, err = tx.ExecContext(ctx,
"UPDATE im_friend_requests SET status=?,handled_at=?,updated_at=? WHERE request_id=? AND status=?",
t.FriendRequestAccepted, handledAt, handledAt, requestID, t.FriendRequestPending); err != nil {
return nil, err
}
row.Status = t.FriendRequestAccepted
row.HandledAt = &handledAt
row.UpdatedAt = handledAt
}
if _, err = tx.ExecContext(ctx, `UPDATE im_friend_requests SET status=?,handled_at=?,updated_at=?
WHERE request_id<>? AND status=? AND
((applicant_id=? AND target_id=?) OR (applicant_id=? AND target_id=?))`,
t.FriendRequestExpired, handledAt, handledAt, requestID, t.FriendRequestPending,
decodeUID(applicant), decodeUID(target), decodeUID(target), decodeUID(applicant)); err != nil {
return nil, err
}
for _, pair := range [][2]t.Uid{{applicant, target}, {target, applicant}} {
friendshipID, idErr := nextSnowflakeID()
if idErr != nil {
return nil, idErr
}
if _, err = tx.ExecContext(ctx,
"INSERT INTO im_friendships(id,user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?,?) "+
"ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)",
friendshipID, decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, handledAt, handledAt); err != nil {
return nil, err
}
}
users, err := a.tenantUserGetAllTx(ctx, tx, id, applicant, target)
if err != nil {
return nil, err
}
if len(users) != 2 {
return nil, t.ErrUserNotFound
}
userByID := map[t.Uid]t.User{}
for _, user := range users {
userByID[user.Uid()] = user
}
applicantUser, ok1 := userByID[applicant]
targetUser, ok2 := userByID[target]
if !ok1 || !ok2 {
return nil, t.ErrUserNotFound
}
applicantSub := &t.Subscription{
ObjHeader: t.ObjHeader{CreatedAt: handledAt, UpdatedAt: handledAt},
TenantID: id,
User: applicant.String(),
Topic: topic,
ModeWant: t.ModeCP2P,
ModeGiven: t.ModeCP2P,
}
applicantSub.SetTouchedAt(handledAt)
applicantSub.SetPublic(targetUser.Public)
applicantSub.SetTrusted(targetUser.Trusted)
targetSub := &t.Subscription{
ObjHeader: t.ObjHeader{CreatedAt: handledAt, UpdatedAt: handledAt},
TenantID: id,
User: target.String(),
Topic: topic,
ModeWant: t.ModeCP2P,
ModeGiven: t.ModeCP2P,
}
targetSub.SetTouchedAt(handledAt)
targetSub.SetPublic(applicantUser.Public)
targetSub.SetTrusted(applicantUser.Trusted)
if err = createSubscription(tx, applicantSub, true); err != nil {
return nil, err
}
if err = createSubscription(tx, targetSub, true); err != nil {
return nil, err
}
topicRow := &t.Topic{TenantID: id, ObjHeader: t.ObjHeader{Id: topic, CreatedAt: handledAt, UpdatedAt: handledAt}, TouchedAt: handledAt}
if a.tenantScopedDB {
err = a.topicCreate(tx, topicRow)
} else {
err = tenantTopicCreate(tx, topicRow)
}
if err != nil {
if !isDupe(err) {
return nil, err
}
if _, err = tx.ExecContext(ctx, "UPDATE im_topics SET updatedat=?,touchedat=?,state=? WHERE name=?",
handledAt, handledAt, t.StateOK, topic); err != nil {
return nil, err
}
}
eventType := "friend.request." + t.FriendRequestAccepted
applicantEventID := t.StableEventID("relation", row.RequestID+":"+t.FriendRequestAccepted, eventType, applicant.UserId())
if err = insertSystemNotificationTx(tx, applicant, t.StableEventID(applicantEventID, "notification"), eventType,
"好友申请已处理", t.FriendRequestAccepted, map[string]any{"type": "friend_request", "request_id": row.RequestID, "user_id": actor.UserId(), "topic": topic}); err != nil {
return nil, err
}
if err = insertBusinessEventTx(tx, applicantEventID, "user", applicant.UserId(), actor, eventType,
map[string]any{"request_id": row.RequestID, "user_id": actor.UserId(), "topic": topic}); err != nil {
return nil, err
}
if err = insertBusinessEventTx(tx, t.StableEventID("relation", row.RequestID+":"+t.FriendRequestAccepted+":self", eventType, actor.UserId()),
"user", actor.UserId(), actor, eventType, map[string]any{"request_id": row.RequestID, "user_id": actor.UserId(), "topic": topic}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.friendRequestByRequestID(ctx, id, requestID)
}
func (a *adapter) TenantFriendRequestResolve(id t.TenantID, requestID string, actor t.Uid, status string, handledAt time.Time) (*t.FriendRequest, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFriendRequestResolve(id, requestID, actor, status, handledAt)
}
}
if err := validTenant(id); err != nil || requestID == "" || actor.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
req, err := a.friendRequestByRequestID(ctx, id, requestID)
if err != nil || req == nil {
return req, err
}
if req.TargetID != actor {
return nil, t.ErrPermissionDenied
}
if req.Status != t.FriendRequestPending {
return req, nil
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx,
"UPDATE im_friend_requests SET status=?,handled_at=?,updated_at=? WHERE request_id=? AND status=?",
status, handledAt, handledAt, requestID, t.FriendRequestPending); err != nil {
return nil, err
}
eventType := "friend.request." + status
eventID := t.StableEventID("relation", req.RequestID+":"+status, eventType, req.ApplicantID.UserId())
if err = insertSystemNotificationTx(tx, req.ApplicantID, t.StableEventID(eventID, "notification"), eventType,
"好友申请已处理", status, map[string]any{"type": "friend_request", "request_id": req.RequestID, "user_id": actor.UserId()}); err != nil {
return nil, err
}
if err = insertBusinessEventTx(tx, eventID, "user", req.ApplicantID.UserId(), actor, eventType,
map[string]any{"request_id": req.RequestID, "user_id": actor.UserId()}); err != nil {
return nil, err
}
if err = insertBusinessEventTx(tx, t.StableEventID("relation", req.RequestID+":"+status+":self", eventType, actor.UserId()),
"user", actor.UserId(), actor, eventType, map[string]any{"request_id": req.RequestID, "user_id": actor.UserId()}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.friendRequestByRequestID(ctx, id, requestID)
}
func (a *adapter) TenantFriendshipUpsertPair(id t.TenantID, uid1, uid2 t.Uid, topic string, now time.Time) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantFriendshipUpsertPair(id, uid1, uid2, topic, now)
}
}
if err := validTenant(id); err != nil || uid1.IsZero() || uid2.IsZero() || uid1 == uid2 {
return t.ErrMalformed
}
if topic == "" {
topic = uid1.P2PName(uid2)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
for _, pair := range [][2]t.Uid{{uid1, uid2}, {uid2, uid1}} {
friendshipID, err := nextSnowflakeID()
if err != nil {
return err
}
if _, err := a.db.ExecContext(ctx,
"INSERT INTO im_friendships(id,user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?,?) "+
"ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)",
friendshipID, decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, now, now); err != nil {
return err
}
}
return nil
}
func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, initial, cursor string, limit int) ([]t.FriendListItem, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantFriendshipList(id, uid, queryText, initial, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
uidID := decodeUID(uid)
where := []string{"fs.user_id=?", "fs.status=?", "fs.peer_id<>?"}
args := []any{uidID, t.RelationshipFriend, uidID}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "fs.peer_id>?")
args = append(args, parsed)
}
if q := strings.TrimSpace(queryText); q != "" {
like := "%" + q + "%"
where = append(where, "(fs.remark LIKE ? OR p.nickname LIKE ? OR p.public_account LIKE ? OR CAST(u.public AS CHAR) LIKE ?)")
args = append(args, like, like, like, like)
}
if initial = strings.TrimSpace(initial); initial != "" {
like := initial + "%"
where = append(where, "(fs.remark LIKE ? OR p.nickname LIKE ? OR CAST(u.public AS CHAR) LIKE ?)")
args = append(args, like, like, like)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT fs.*,u.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
FROM im_friendships AS fs
JOIN im_users AS u ON u.id=fs.peer_id
LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY fs.peer_id ASC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
items := make([]t.FriendListItem, 0, limit)
nextCursor := ""
for rows.Next() {
row := struct {
ID int64
UserID int64 `db:"user_id"`
PeerID int64 `db:"peer_id"`
Status string
Remark string
Topic string
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
Public any
Trusted any
ProfileNickname sql.NullString `db:"profile_nickname"`
ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"`
ProfileSignature sql.NullString `db:"profile_signature"`
ProfileGender sql.NullString `db:"profile_gender"`
ProfileRegion sql.NullString `db:"profile_region"`
ProfilePublicAccount sql.NullString `db:"profile_public_account"`
ProfileUpdatedAt sql.NullTime `db:"profile_updated_at"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
if len(items) == limit {
nextCursor = strconv.FormatInt(row.PeerID, 10)
continue
}
peerID := store.EncodeUid(row.PeerID)
items = append(items, t.FriendListItem{
Friendship: t.Friendship{
TenantID: id,
UserID: store.EncodeUid(row.UserID),
PeerID: peerID,
Status: row.Status,
Remark: row.Remark,
Topic: row.Topic,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
},
Peer: &t.User{
ObjHeader: t.ObjHeader{Id: peerID.String()},
TenantID: id,
Public: profilePublicFromFields(common.FromJSON(row.Public), row.ProfileNickname, row.ProfileAvatarFileID, row.ProfileSignature, row.ProfileGender, row.ProfileRegion, row.ProfilePublicAccount),
Trusted: common.FromJSON(row.Trusted),
},
})
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return items, nextCursor, nil
}
func (a *adapter) TenantFriendshipGet(id t.TenantID, userID, peerID t.Uid) (*t.Friendship, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFriendshipGet(id, userID, peerID)
}
}
if err := validTenant(id); err != nil || userID.IsZero() || peerID.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
row := struct {
UserID int64 `db:"user_id"`
PeerID int64 `db:"peer_id"`
Status string
Remark string
Topic string
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
err := a.db.GetContext(ctx, &row, `SELECT user_id,peer_id,status,remark,topic,created_at,updated_at
FROM im_friendships WHERE user_id=? AND peer_id=?`,
decodeUID(userID), decodeUID(peerID))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &t.Friendship{
TenantID: id,
UserID: store.EncodeUid(row.UserID),
PeerID: store.EncodeUid(row.PeerID),
Status: row.Status,
Remark: row.Remark,
Topic: row.Topic,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}, nil
}
func (a *adapter) TenantFriendshipUpdateRemark(id t.TenantID, userID, peerID t.Uid, remark string, now time.Time) (*t.Friendship, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFriendshipUpdateRemark(id, userID, peerID, remark, now)
}
}
if err := validTenant(id); err != nil || userID.IsZero() || peerID.IsZero() || len([]rune(remark)) > 64 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx,
"UPDATE im_friendships SET remark=?,updated_at=? WHERE user_id=? AND peer_id=? AND status=?",
remark, now, decodeUID(userID), decodeUID(peerID), t.RelationshipFriend)
if err != nil {
return nil, err
}
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
return nil, t.ErrNotFound
}
return a.TenantFriendshipGet(id, userID, peerID)
}
func (a *adapter) TenantFriendshipDeletePair(id t.TenantID, uid1, uid2 t.Uid, now time.Time) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantFriendshipDeletePair(id, uid1, uid2, now)
}
}
if err := validTenant(id); err != nil || uid1.IsZero() || uid2.IsZero() {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx,
"UPDATE im_friendships SET status=?,updated_at=? WHERE (user_id=? AND peer_id=?) OR (user_id=? AND peer_id=?)",
t.RelationshipNone, now, decodeUID(uid1), decodeUID(uid2), decodeUID(uid2), decodeUID(uid1))
return err
}
func (a *adapter) TenantUserBlockGet(id t.TenantID, blockerID, blockedID t.Uid) (*t.UserBlock, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantUserBlockGet(id, blockerID, blockedID)
}
}
if err := validTenant(id); err != nil || blockerID.IsZero() || blockedID.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
row := struct {
BlockerID int64 `db:"blocker_id"`
BlockedID int64 `db:"blocked_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
err := a.db.GetContext(ctx, &row, `SELECT blocker_id,blocked_id,created_at,updated_at
FROM im_user_blocks WHERE blocker_id=? AND blocked_id=?`,
decodeUID(blockerID), decodeUID(blockedID))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &t.UserBlock{
TenantID: id,
BlockerID: store.EncodeUid(row.BlockerID),
BlockedID: store.EncodeUid(row.BlockedID),
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}, nil
}
func (a *adapter) TenantUserBlockList(id t.TenantID, blockerID t.Uid, cursor string, limit int) ([]t.BlockListItem, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantUserBlockList(id, blockerID, cursor, limit)
}
}
if err := validTenant(id); err != nil || blockerID.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
where := []string{"b.blocker_id=?"}
args := []any{decodeUID(blockerID)}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "b.blocked_id<?")
args = append(args, parsed)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT b.blocker_id,b.blocked_id,b.created_at,b.updated_at,u.public,u.trusted
FROM im_user_blocks AS b
LEFT JOIN im_users AS u ON u.id=b.blocked_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY b.blocked_id DESC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
items := make([]t.BlockListItem, 0, limit)
nextCursor := ""
for rows.Next() {
row := struct {
BlockerID int64 `db:"blocker_id"`
BlockedID int64 `db:"blocked_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
Public any
Trusted any
}{}
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
if len(items) == limit {
nextCursor = strconv.FormatInt(row.BlockedID, 10)
continue
}
blockedID := store.EncodeUid(row.BlockedID)
items = append(items, t.BlockListItem{
Block: t.UserBlock{
TenantID: id,
BlockerID: store.EncodeUid(row.BlockerID),
BlockedID: blockedID,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
},
User: &t.User{
ObjHeader: t.ObjHeader{Id: blockedID.String()},
TenantID: id,
Public: common.FromJSON(row.Public),
Trusted: common.FromJSON(row.Trusted),
},
})
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return items, nextCursor, nil
}
func (a *adapter) TenantUserBlockUpsert(id t.TenantID, blockerID, blockedID t.Uid, now time.Time) (*t.UserBlock, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantUserBlockUpsert(id, blockerID, blockedID, now)
}
}
if err := validTenant(id); err != nil || blockerID.IsZero() || blockedID.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
blockID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
_, err = a.db.ExecContext(ctx,
"INSERT INTO im_user_blocks(id,blocker_id,blocked_id,created_at,updated_at) VALUES(?,?,?,?,?) "+
"ON DUPLICATE KEY UPDATE updated_at=VALUES(updated_at)",
blockID, decodeUID(blockerID), decodeUID(blockedID), now, now)
if err != nil {
return nil, err
}
return a.TenantUserBlockGet(id, blockerID, blockedID)
}
func (a *adapter) TenantUserBlockDelete(id t.TenantID, blockerID, blockedID t.Uid) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantUserBlockDelete(id, blockerID, blockedID)
}
}
if err := validTenant(id); err != nil || blockerID.IsZero() || blockedID.IsZero() {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx, "DELETE FROM im_user_blocks WHERE blocker_id=? AND blocked_id=?",
decodeUID(blockerID), decodeUID(blockedID))
return err
}
func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searchType, cursor string, limit int) ([]t.User, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantUserSearch(id, viewer, queryText, searchType, cursor, limit)
}
}
if err := validTenant(id); err != nil || viewer.IsZero() {
return nil, "", t.ErrMalformed
}
queryText = strings.TrimSpace(queryText)
if queryText == "" {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 50 {
limit = 20
}
where := []string{"u.id<>?", "u.state=?"}
args := []any{decodeUID(viewer), t.StateOK}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "u.id>?")
args = append(args, parsed)
}
joins := []string{
"LEFT JOIN im_user_privacy_settings AS privacy ON privacy.user_id=u.id",
"LEFT JOIN im_user_profiles AS profile ON profile.user_id=u.id",
}
addMethod := "account"
switch searchType {
case "", "account":
like := "%" + queryText + "%"
phoneCandidate := queryText
if phone, err := normalizeUserPhone(queryText); err == nil {
phoneCandidate = phone
}
where = append(where, "(profile.nickname LIKE ? OR profile.public_account LIKE ? OR u.username LIKE ? OR u.phone=? OR u.phone=? OR CAST(u.public AS CHAR) LIKE ? OR u.id=?)")
args = append(args, like, like, like, queryText, phoneCandidate, like, userIDNumericArg(queryText))
case "exact_account":
where = append(where, "(profile.public_account=? OR u.username=? OR u.id=?)")
args = append(args, queryText, strings.TrimPrefix(queryText, "basic:"), userIDNumericArg(queryText))
case "phone":
addMethod = "phone"
where = append(where, "u.phone=?")
args = append(args, queryText)
default:
return nil, "", t.ErrMalformed
}
where = append(where, `(EXISTS (
SELECT 1 FROM im_friendships AS privacy_friend
WHERE privacy_friend.user_id=? AND privacy_friend.peer_id=u.id AND privacy_friend.status=?
) OR COALESCE(JSON_UNQUOTE(JSON_EXTRACT(privacy.add_me_methods, ?)), 'true')='true')`)
args = append(args, decodeUID(viewer), t.RelationshipFriend, "$."+addMethod)
args = append([]any{id}, args...)
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT ? AS tenant_id,u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,u.public,u.trusted,
profile.nickname AS profile_nickname,profile.avatar_file_id AS profile_avatar_file_id,profile.signature AS profile_signature,
profile.gender AS profile_gender,profile.region AS profile_region,profile.public_account AS profile_public_account,profile.updated_at AS profile_updated_at
FROM im_users AS u `+strings.Join(joins, " ")+`
WHERE `+strings.Join(where, " AND ")+` ORDER BY u.id ASC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
users := make([]t.User, 0, limit)
nextCursor := ""
for rows.Next() {
var row profileUserRow
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
user := profileUserFromRow(row)
if len(users) == limit {
nextCursor = strconv.FormatInt(decodeUID(user.Uid()), 10)
continue
}
users = append(users, user)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return users, nextCursor, nil
}
func userIDNumericArg(value string) int64 {
if uid := t.ParseUserId(value); !uid.IsZero() {
return decodeUID(uid)
}
return 0
}
func (a *adapter) friendRequestByRequestID(ctx context.Context, tenantID t.TenantID, requestID string) (*t.FriendRequest, error) {
return a.friendRequestByQuery(ctx, tenantID, "SELECT * FROM im_friend_requests WHERE request_id=?", requestID)
}
func (a *adapter) friendRequestByIdempotency(ctx context.Context, tenantID t.TenantID, applicantID t.Uid, key string) (*t.FriendRequest, error) {
return a.friendRequestByQuery(ctx, tenantID, "SELECT * FROM im_friend_requests WHERE applicant_id=? AND idempotency_key=?", decodeUID(applicantID), key)
}
func (a *adapter) friendRequestByQuery(ctx context.Context, tenantID t.TenantID, query string, args ...any) (*t.FriendRequest, error) {
row := struct {
ID int64
RequestID string `db:"request_id"`
ApplicantID int64 `db:"applicant_id"`
TargetID int64 `db:"target_id"`
Message string
Source string
SourceTopic string `db:"source_topic"`
Status string
IdempotencyKey string `db:"idempotency_key"`
ExpiresAt time.Time `db:"expires_at"`
HandledAt *time.Time `db:"handled_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
err := a.db.GetContext(ctx, &row, query, args...)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &t.FriendRequest{
ID: row.ID,
TenantID: tenantID,
RequestID: row.RequestID,
ApplicantID: store.EncodeUid(row.ApplicantID),
TargetID: store.EncodeUid(row.TargetID),
Message: row.Message,
Source: row.Source,
SourceTopic: row.SourceTopic,
Status: row.Status,
IdempotencyKey: row.IdempotencyKey,
ExpiresAt: row.ExpiresAt,
HandledAt: row.HandledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}, nil
}
func nullableUID(uid t.Uid) any {
if uid.IsZero() {
return nil
}
return store.DecodeUid(uid)
}
func securityTokenHash(tenantID t.TenantID, uid t.Uid, action, target, tokenID, secret string) string {
h := sha256.New()
h.Write([]byte(strconv.FormatInt(int64(tenantID), 10)))
h.Write([]byte{0})
h.Write([]byte(strconv.FormatInt(store.DecodeUid(uid), 10)))
h.Write([]byte{0})
h.Write([]byte(action))
h.Write([]byte{0})
h.Write([]byte(target))
h.Write([]byte{0})
h.Write([]byte(tokenID))
h.Write([]byte{0})
h.Write([]byte(secret))
return fmt.Sprintf("%x", h.Sum(nil))
}