9970 lines
307 KiB
Go
9970 lines
307 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"`
|
|
FriendshipRemark sql.NullString `db:"friendship_remark"`
|
|
}
|
|
|
|
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 ensureP2PPrivatePeer(private any, peerID string) any {
|
|
return ensureP2PPrivatePeerRemark(private, peerID, "")
|
|
}
|
|
|
|
func ensureP2PPrivatePeerRemark(private any, peerID, remark string) any {
|
|
if strings.TrimSpace(peerID) == "" {
|
|
return private
|
|
}
|
|
data, _ := private.(map[string]any)
|
|
if data == nil {
|
|
data = map[string]any{}
|
|
}
|
|
if _, ok := data["peer"]; !ok {
|
|
data["peer"] = peerID
|
|
}
|
|
if remark = strings.TrimSpace(remark); remark != "" {
|
|
data["remark"] = remark
|
|
}
|
|
return data
|
|
}
|
|
|
|
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
|
|
}
|
|
q += " ORDER BY topic ASC, deletedat IS NULL DESC, updatedat DESC, id DESC"
|
|
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{}
|
|
p2pResolved := map[string]bool{}
|
|
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()
|
|
if !t.ParseUserId(tname).IsZero() {
|
|
continue
|
|
}
|
|
peerID := ""
|
|
switch t.GetTopicCat(tname) {
|
|
case t.TopicCatMe, t.TopicCatFnd:
|
|
continue
|
|
case t.TopicCatP2P:
|
|
uid1, uid2, _ := t.ParseP2P(tname)
|
|
p2pResolved[tname] = false
|
|
if uid1 == uid {
|
|
usrq = append(usrq, decodeUID(uid2))
|
|
peerID = uid2.UserId()
|
|
sub.SetWith(peerID)
|
|
} else {
|
|
usrq = append(usrq, decodeUID(uid1))
|
|
peerID = uid1.UserId()
|
|
sub.SetWith(peerID)
|
|
}
|
|
case t.TopicCatGrp:
|
|
tname = t.ChnToGrp(tname)
|
|
}
|
|
if _, exists := join[tname]; exists {
|
|
continue
|
|
}
|
|
topq = append(topq, tname)
|
|
sub.Private = common.FromJSON(sub.Private)
|
|
if t.GetTopicCat(sub.Topic) == t.TopicCatP2P {
|
|
sub.Private = ensureP2PPrivatePeer(sub.Private, peerID)
|
|
}
|
|
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,
|
|
fs.remark AS friendship_remark
|
|
FROM im_users AS u
|
|
LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
|
|
LEFT JOIN im_friendships AS fs ON fs.user_id=? AND fs.peer_id=u.id AND fs.status=?
|
|
WHERE u.tenant_id=? AND u.id IN (?)`
|
|
q, args, _ = sqlx.In(q, decodeUID(uid), t.RelationshipFriend, 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)
|
|
sub.Private = ensureP2PPrivatePeerRemark(sub.Private, common.EncodeUidString(user.ID).UserId(), user.FriendshipRemark.String)
|
|
join[joinOn] = sub
|
|
p2pResolved[joinOn] = true
|
|
}
|
|
}
|
|
err = rows.Err()
|
|
rows.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
subs := make([]t.Subscription, 0, len(join))
|
|
for _, sub := range join {
|
|
if t.GetTopicCat(sub.Topic) == t.TopicCatP2P && !p2pResolved[sub.Topic] {
|
|
continue
|
|
}
|
|
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 = 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 = 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 = 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 = 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()
|
|
}
|
|
if err := a.finalizeStaleCalls(ctx); err != nil {
|
|
return nil, "", err
|
|
}
|
|
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) finalizeStaleCalls(ctx context.Context) error {
|
|
now := t.TimeNow()
|
|
startedBefore := now.Add(-30 * time.Second)
|
|
acceptedBefore := now.Add(-12 * time.Hour)
|
|
if _, err := a.db.ExecContext(ctx, `UPDATE im_call_records
|
|
SET status=?,end_reason=?,duration_ms=0,finished_at=?,updated_at=?
|
|
WHERE status=? AND started_at<? AND finished_at IS NULL`,
|
|
t.CallStatusMissed, t.CallStatusMissed, now, now, t.CallStatusStarted, startedBefore); err != nil {
|
|
return err
|
|
}
|
|
_, err := a.db.ExecContext(ctx, `UPDATE im_call_records
|
|
SET status=?,end_reason=?,duration_ms=GREATEST(TIMESTAMPDIFF(MICROSECOND,accepted_at,?) DIV 1000,0),finished_at=?,updated_at=?
|
|
WHERE status=? AND accepted_at IS NOT NULL AND accepted_at<? AND finished_at IS NULL`,
|
|
t.CallStatusDisconnected, t.CallStatusDisconnected, now, now, now, t.CallStatusAccepted, acceptedBefore)
|
|
return err
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
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, ¤t, `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)
|
|
}
|
|
if patch.GroupInviteVerification != nil {
|
|
sets = append(sets, "group_invite_verification=?")
|
|
args = append(args, *patch.GroupInviteVerification)
|
|
}
|
|
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) 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"`
|
|
GroupInviteVerification bool `db:"group_invite_verification"`
|
|
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,group_invite_verification,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,
|
|
GroupInviteVerification: row.GroupInviteVerification,
|
|
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(¬ification); 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 = 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 = 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 = 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()
|
|
}
|
|
res, 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))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
|
|
return t.ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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))
|
|
}
|