7392 lines
234 KiB
Go
7392 lines
234 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"`
|
|
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(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(nickname, avatarFileID, signature, gender, region, publicAccount sql.NullString) any {
|
|
public := map[string]any{}
|
|
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 ensurePrivateMutationVersion(private any, version int64) any {
|
|
data, _ := private.(map[string]any)
|
|
if data == nil {
|
|
data = map[string]any{}
|
|
}
|
|
data["mutation_version"] = version
|
|
return data
|
|
}
|
|
|
|
func sanitizeUserUpdate(update map[string]any) map[string]any {
|
|
update = withoutUserTags(update)
|
|
delete(update, "Public")
|
|
delete(update, "public")
|
|
return update
|
|
}
|
|
|
|
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
|
|
if rawID, err := strconv.ParseInt(msg.Id, 10, 64); err == nil && rawID > 0 {
|
|
msg.SetUid(store.EncodeUid(rawID))
|
|
}
|
|
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 (a *adapter) tenantTopicBumpMutationVersionTx(ctx context.Context, tx *sqlx.Tx, id t.TenantID, topic string, at time.Time) error {
|
|
if a.tenantScopedDB {
|
|
_, err := tx.ExecContext(ctx, "UPDATE im_topics SET mutation_version=mutation_version+1,updatedat=?,touchedat=? WHERE name=?",
|
|
at, at, topic)
|
|
return err
|
|
}
|
|
_, err := tx.ExecContext(ctx, "UPDATE im_topics SET mutation_version=mutation_version+1,updatedat=?,touchedat=? WHERE tenant_id=? AND name=?",
|
|
at, at, id, topic)
|
|
return err
|
|
}
|
|
|
|
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 != a {
|
|
return tenantAdp.TenantUserCreate(id, user)
|
|
}
|
|
ctx, cancel := a.getContextForTx()
|
|
if cancel != nil {
|
|
defer cancel()
|
|
}
|
|
tx, err := a.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
decodedUID := decodeUID(user.Uid())
|
|
securityID, err := nextSnowflakeID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
profileID, err := nextSnowflakeID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var phone any
|
|
if strings.TrimSpace(user.Phone) != "" {
|
|
canonicalPhone, normalizeErr := normalizeUserPhone(user.Phone)
|
|
if normalizeErr != nil {
|
|
return normalizeErr
|
|
}
|
|
user.Phone = canonicalPhone
|
|
phone = canonicalPhone
|
|
}
|
|
accountNo, hasAccountNo, err := nextTenantUserAccountNo(ctx, tx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
nickname, avatarFileID := profileSeedFromPublic(user.Public)
|
|
insert := "INSERT INTO im_users(tenant_id,id,username,createdat,updatedat,state,access,phone,trusted) VALUES(?,?,?,?,?,?,?,?,?)"
|
|
args := []any{id, decodedUID, user.Username, user.CreatedAt, user.UpdatedAt, user.State, user.Access, phone, common.ToJSON(user.Trusted)}
|
|
if hasAccountNo {
|
|
insert = "INSERT INTO im_users(tenant_id,id,account_no,username,createdat,updatedat,state,access,phone,trusted) VALUES(?,?,?,?,?,?,?,?,?,?)"
|
|
args = []any{id, decodedUID, accountNo, user.Username, user.CreatedAt, user.UpdatedAt, user.State, user.Access, phone, common.ToJSON(user.Trusted)}
|
|
}
|
|
if _, err = tx.Exec(insert, args...); err != nil {
|
|
if isDupe(err) {
|
|
return t.ErrDuplicate
|
|
}
|
|
return err
|
|
}
|
|
if _, err = tx.Exec("INSERT INTO im_user_security(tenant_id,id,user_id,auth_version,created_at,updated_at) VALUES(?,?,?,?,?,?)",
|
|
id, securityID, decodedUID, 1, user.CreatedAt, user.UpdatedAt); err != nil {
|
|
return err
|
|
}
|
|
if _, err = tx.Exec(`INSERT IGNORE INTO im_user_profiles(tenant_id,id,user_id,avatar_file_id,nickname,public_account,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,?)`, id, profileID, decodedUID, avatarFileID, nickname, user.Uid().UserId(), user.CreatedAt, user.UpdatedAt); err != nil {
|
|
return err
|
|
}
|
|
user.Tags = nil
|
|
return tx.Commit()
|
|
}
|
|
|
|
func nextTenantUserAccountNo(ctx context.Context, tx *sqlx.Tx, tenantID t.TenantID) (uint32, bool, error) {
|
|
var columns int
|
|
if err := tx.GetContext(ctx, &columns, `SELECT COUNT(*) FROM information_schema.COLUMNS
|
|
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='im_users' AND COLUMN_NAME='account_no'`); err != nil {
|
|
return 0, false, err
|
|
}
|
|
if columns == 0 {
|
|
return 0, false, nil
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `INSERT IGNORE INTO im_user_account_sequences(tenant_id,id,next_value)
|
|
SELECT ?,1,GREATEST(COALESCE(MAX(account_no),999),999) FROM im_users WHERE tenant_id=? AND account_no>0`,
|
|
tenantID, tenantID); err != nil {
|
|
return 0, true, err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `UPDATE im_user_account_sequences
|
|
SET next_value=GREATEST(next_value,(SELECT GREATEST(COALESCE(MAX(account_no),999),999) FROM im_users WHERE tenant_id=? AND account_no>0))
|
|
WHERE tenant_id=? AND id=1`, tenantID, tenantID); err != nil {
|
|
return 0, true, err
|
|
}
|
|
result, err := tx.ExecContext(ctx, `UPDATE im_user_account_sequences
|
|
SET next_value=LAST_INSERT_ID(next_value+1) WHERE tenant_id=? AND id=1`, tenantID)
|
|
if err != nil {
|
|
return 0, true, err
|
|
}
|
|
if affected, affectedErr := result.RowsAffected(); affectedErr != nil {
|
|
return 0, true, affectedErr
|
|
} else if affected != 1 {
|
|
return 0, true, fmt.Errorf("mysql: user account sequence is not initialized for tenant %d", tenantID)
|
|
}
|
|
var next uint64
|
|
if err = tx.GetContext(ctx, &next, "SELECT LAST_INSERT_ID()"); err != nil {
|
|
return 0, true, err
|
|
}
|
|
if next > uint64(^uint32(0)) {
|
|
return 0, true, fmt.Errorf("mysql: user account number exhausted for tenant %d", tenantID)
|
|
}
|
|
return uint32(next), true, nil
|
|
}
|
|
|
|
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 {
|
|
return tenantAdp.TenantUserGet(id, uid)
|
|
}
|
|
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 u.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.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.tenant_id=u.tenant_id AND p.user_id=u.id
|
|
WHERE u.tenant_id=? AND 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 {
|
|
return tenantAdp.TenantUserGetByPhone(id, phone)
|
|
}
|
|
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 {
|
|
return tenantAdp.TenantUserGetAll(id, uids...)
|
|
}
|
|
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 u.id AS id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
|
|
COALESCE(u.phone,'') AS phone,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.tenant_id=u.tenant_id AND p.user_id=u.id
|
|
WHERE u.tenant_id=? AND u.id IN (?) AND u.state!=?`, id, args, t.StateDeleted)
|
|
} else {
|
|
q, params, _ = sqlx.In(`SELECT u.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.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.tenant_id=u.tenant_id AND p.user_id=u.id
|
|
WHERE u.tenant_id=? AND u.id IN (?) AND u.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 row profileUserRow
|
|
if err = rows.StructScan(&row); err != nil {
|
|
return nil, err
|
|
}
|
|
users = append(users, profileUserFromRow(row))
|
|
}
|
|
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.TenantUserDelete(id, 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(fmt.Sprintf("DELETE m FROM %s 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=?", tenantMessagesTable(id)), 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 tenant_id=? AND userid=?", id, decoded); err != nil {
|
|
return err
|
|
}
|
|
if _, err = tx.Exec("DELETE FROM im_credentials WHERE tenant_id=? AND userid=?", id, 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.TenantUserUpdate(id, 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 = sanitizeUserUpdate(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.TenantUserUpdateTags(id, 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.TenantUserUnreadCount(id, 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.TenantUserGetUnvalidated(id, 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 c.tenant_id=u.tenant_id AND u.id=c.userid "+
|
|
"WHERE u.tenant_id=? AND u.lastseen IS NULL AND u.updatedat<? "+
|
|
"GROUP BY u.id,u.updatedat HAVING total=0 ORDER BY u.updatedat ASC LIMIT ?", id, 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)
|
|
}
|
|
|
|
// createTenantSubscription mirrors createSubscription but keeps the subscription
|
|
// bound to the logical tenant in shared-database deployments.
|
|
func createTenantSubscription(tx *sqlx.Tx, tenantID t.TenantID, sub *t.Subscription, undelete bool) error {
|
|
if sub == nil || tenantID.IsZero() || sub.TenantID != tenantID {
|
|
return t.ErrMalformed
|
|
}
|
|
isOwner := (sub.ModeGiven & sub.ModeWant).IsOwner()
|
|
|
|
jpriv := common.ToJSON(sub.Private)
|
|
decodedUID := decodeUID(t.ParseUid(sub.User))
|
|
|
|
existing := struct {
|
|
ID int64 `db:"id"`
|
|
DeletedAt sql.NullTime `db:"deletedat"`
|
|
}{}
|
|
err := tx.Get(&existing,
|
|
"SELECT id,deletedat FROM im_subscriptions WHERE tenant_id=? AND topic=? AND userid=? ORDER BY deletedat IS NULL DESC,id ASC LIMIT 1",
|
|
tenantID, sub.Topic, decodedUID)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
return err
|
|
}
|
|
if err == nil {
|
|
if existing.DeletedAt.Valid {
|
|
_, err = tx.Exec("UPDATE im_subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+
|
|
"delid=0,recvseqid=0,readseqid=0,private=? WHERE tenant_id=? AND id=?",
|
|
sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, tenantID, existing.ID)
|
|
} else {
|
|
_, err = tx.Exec("UPDATE im_subscriptions SET updatedat=?,modeWant=?,modeGiven=?,private=? WHERE tenant_id=? AND id=?",
|
|
sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, tenantID, existing.ID)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if isOwner {
|
|
_, err = tx.Exec("UPDATE im_topics SET owner=? WHERE tenant_id=? AND name=?", decodedUID, tenantID, sub.Topic)
|
|
}
|
|
return err
|
|
}
|
|
|
|
subID, err := nextSnowflakeID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.Exec(
|
|
"INSERT INTO im_subscriptions(id,tenant_id,createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+
|
|
"VALUES(?,?,?,?,NULL,?,?,?,?,?)",
|
|
subID, tenantID, sub.CreatedAt, sub.UpdatedAt, decodedUID, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv)
|
|
if isIDOutOfRange(err) {
|
|
_, err = tx.Exec(
|
|
"INSERT INTO im_subscriptions(tenant_id,createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+
|
|
"VALUES(?,?,?,NULL,?,?,?,?,?)",
|
|
tenantID, sub.CreatedAt, sub.UpdatedAt, decodedUID, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv)
|
|
}
|
|
|
|
if err != nil && isDupe(err) {
|
|
if undelete {
|
|
_, err = tx.Exec("UPDATE im_subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+
|
|
"delid=0,recvseqid=0,readseqid=0 WHERE tenant_id=? AND topic=? AND userid=?",
|
|
sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), tenantID, sub.Topic, decodedUID)
|
|
} else {
|
|
_, err = tx.Exec("UPDATE im_subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+
|
|
"delid=0,recvseqid=0,readseqid=0,private=? WHERE tenant_id=? AND topic=? AND userid=?",
|
|
sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, tenantID, sub.Topic, decodedUID)
|
|
}
|
|
}
|
|
|
|
if err == nil && isOwner {
|
|
_, err = tx.Exec("UPDATE im_topics SET owner=? WHERE tenant_id=? AND name=?", decodedUID, tenantID, sub.Topic)
|
|
}
|
|
return err
|
|
}
|
|
|
|
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.TenantTopicCreate(id, 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.TenantTopicCreateP2P(id, 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 = createTenantSubscription(tx, id, one, false); err != nil {
|
|
return err
|
|
}
|
|
if err = createTenantSubscription(tx, id, 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 tenant_id=? AND name=? ORDER BY id ASC LIMIT 1", id, 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 tenant_id=? AND id=?",
|
|
topic.UpdatedAt, topic.TouchedAt, t.StateOK, id, 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 {
|
|
return tenantAdp.TenantTopicGet(id, topic)
|
|
}
|
|
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,IFNULL(mutation_version,0) AS mutation_version,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 {
|
|
return tenantAdp.TenantTopicsForUser(id, uid, keepDeleted, opts)
|
|
}
|
|
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,IFNULL(mutation_version,0) AS mutation_version,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)
|
|
sub.Private = ensurePrivateMutationVersion(sub.Private, top.MutationVersion)
|
|
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 = fmt.Sprintf(`SELECT u.tenant_id,u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,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.tenant_id=u.tenant_id AND p.user_id=u.id
|
|
LEFT JOIN %s AS fs ON fs.tenant_id=u.tenant_id AND fs.user_id=? AND fs.peer_id=u.id AND fs.status=?
|
|
WHERE u.tenant_id=? AND u.id IN (?)`, tenantFriendshipsTable(id))
|
|
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 {
|
|
return tenantAdp.TenantUsersForTopic(id, topic, keepDeleted, opts)
|
|
}
|
|
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.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 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,
|
|
&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(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.TenantOwnTopics(id, 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.TenantChannelsForUser(id, 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.TenantTopicShare(id, 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 = createTenantSubscription(tx, id, 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 {
|
|
messageTable := tenantMessagesTable(tenantID)
|
|
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(fmt.Sprintf("DELETE FROM %s WHERE tenant_id=? AND topic=?", messageTable), 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, fmt.Sprintf("SELECT seqid FROM %s AS m WHERE ", messageTable)+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(fmt.Sprintf("DELETE fml.* FROM im_filemsglinks AS fml INNER JOIN %s AS m ON m.tenant_id=fml.tenant_id AND m.id=fml.msgid WHERE ", messageTable)+where, args...); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(fmt.Sprintf("UPDATE %s AS m SET m.deletedat=?,m.delid=?,m.`from`=0,m.head=NULL,m.content=NULL WHERE ", messageTable)+
|
|
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, tenantID t.TenantID, topic string, toDel *t.DelMessage) error {
|
|
var err error
|
|
ctx := context.Background()
|
|
tables, err := groupMessageTablesForTopic(tx, ctx, tenantID, topic)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if toDel == nil {
|
|
if _, err = tx.Exec("DELETE FROM im_dellog WHERE tenant_id=? AND topic=?", tenantID, topic); err != nil {
|
|
return err
|
|
}
|
|
for _, table := range tables {
|
|
if _, err = tx.Exec("DELETE FROM "+table+" WHERE tenant_id=? AND topic=?", tenantID, topic); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err = tx.Exec("DELETE FROM im_group_message_topic_shards WHERE tenant_id=? AND topic=?", tenantID, topic); err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.Exec("DELETE FROM im_group_message_dedup WHERE tenant_id=? AND topic=?", tenantID, topic)
|
|
return err
|
|
}
|
|
delRanges := toDel.SeqIdRanges
|
|
if toDel.DeletedFor == "" {
|
|
seqIDs := make([]int64, 0)
|
|
for _, table := range tables {
|
|
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.deleted_at IS NULL"
|
|
if newerThan := toDel.GetNewerThan(); newerThan != nil {
|
|
where += " AND m.created_at>?"
|
|
args = append(args, newerThan)
|
|
}
|
|
var tableSeqIDs []int64
|
|
if err = tx.Select(&tableSeqIDs, "SELECT seqid FROM "+table+" AS m WHERE "+where, args...); err != nil {
|
|
return err
|
|
}
|
|
seqIDs = append(seqIDs, tableSeqIDs...)
|
|
}
|
|
if len(seqIDs) == 0 {
|
|
return nil
|
|
}
|
|
sort.Slice(seqIDs, func(i, j int) bool { return seqIDs[i] < seqIDs[j] })
|
|
uniqueSeqIDs := seqIDs[:0]
|
|
for _, seqID := range seqIDs {
|
|
if len(uniqueSeqIDs) == 0 || uniqueSeqIDs[len(uniqueSeqIDs)-1] != seqID {
|
|
uniqueSeqIDs = append(uniqueSeqIDs, seqID)
|
|
}
|
|
}
|
|
delRanges = t.SliceToRanges(uniqueSeqIDs)
|
|
rSQL, rArgs := common.RangesToSql(delRanges)
|
|
where := "m.tenant_id=? AND m.topic=? AND m.seqid " + rSQL
|
|
args := append([]any{tenantID, topic}, rArgs...)
|
|
now := t.TimeNow()
|
|
for _, table := range tables {
|
|
if _, err = tx.Exec("UPDATE "+table+" 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{now, now}, 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 (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.TenantTopicDelete(id, 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, id, 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.TenantTopicUpdateOnMessage(id, 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.TenantTopicUpdateSubCnt(id, 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.TenantTopicUpdate(id, 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) TenantTopicBumpMutationVersion(id t.TenantID, topic string, at time.Time) (int64, error) {
|
|
tenantAdp, err := a.routedTenantAdapter(id)
|
|
if err != nil || topic == "" {
|
|
return 0, t.ErrMalformed
|
|
}
|
|
if tenantAdp != a {
|
|
return tenantAdp.TenantTopicBumpMutationVersion(id, topic, at)
|
|
}
|
|
if at.IsZero() {
|
|
at = time.Now().UTC().Round(time.Millisecond)
|
|
}
|
|
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()
|
|
}
|
|
}()
|
|
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, at); err != nil {
|
|
return 0, err
|
|
}
|
|
var version int64
|
|
query := "SELECT mutation_version FROM im_topics WHERE name=?"
|
|
args := []any{topic}
|
|
if !a.tenantScopedDB {
|
|
query = "SELECT mutation_version FROM im_topics WHERE tenant_id=? AND name=?"
|
|
args = []any{id, topic}
|
|
}
|
|
if err = tx.GetContext(ctx, &version, query, args...); err != nil {
|
|
return 0, err
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
return 0, err
|
|
}
|
|
return version, nil
|
|
}
|
|
|
|
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.TenantTopicOwnerChange(id, 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 {
|
|
return tenantAdp.TenantSubscriptionGet(id, topic, uid, keepDeleted)
|
|
}
|
|
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 {
|
|
return tenantAdp.TenantSubsForUser(id, uid)
|
|
}
|
|
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 {
|
|
return tenantAdp.TenantSubsForTopic(id, topic, keepDeleted, opts)
|
|
}
|
|
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 := 0
|
|
if opts != nil {
|
|
limit = a.maxResults
|
|
if !opts.User.IsZero() {
|
|
q += " AND userid=?"
|
|
args = append(args, decodeUID(opts.User))
|
|
}
|
|
if opts.Limit > 0 && opts.Limit < limit {
|
|
limit = opts.Limit
|
|
}
|
|
}
|
|
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
|
|
}
|
|
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.TenantSubsUpdate(id, 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.TenantSubsDelete(id, 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 {
|
|
return tenantAdp.TenantFind(id, caller, promoPrefix, req, opt, activeOnly)
|
|
}
|
|
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.TenantFindOne(id, 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,
|
|
fmt.Sprintf("INSERT INTO %s(tenant_id,createdat,updatedat,seqid,topic,`from`,client_msg_id,version,head,content,content_nonce,key_version) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", tenantMessagesTable(id)),
|
|
id, msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content), msg.ContentNonce, msg.KeyVersion)
|
|
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)
|
|
}
|
|
if err = a.ensureCurrentGroupMessageShardWindow(); err != nil {
|
|
return err
|
|
}
|
|
ctx, cancel := a.getContext()
|
|
if cancel != nil {
|
|
defer cancel()
|
|
}
|
|
groupID, err := a.groupIDByTopic(ctx, id, msg.Topic)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if msg.ShardMonth == "" {
|
|
msg.ShardMonth = msg.CreatedAt.UTC().Format("200601")
|
|
}
|
|
table, ok := groupMessageShardTable(id, msg.ShardMonth)
|
|
if !ok || msg.Uid().IsZero() {
|
|
return t.ErrMalformed
|
|
}
|
|
if msg.ShardMonth == time.Now().UTC().Format("200601") {
|
|
if _, err = a.db.ExecContext(ctx, "UPDATE im_group_message_shards SET status='writable' WHERE tenant_id=? AND shard_month=? AND status='prepared'", id, msg.ShardMonth); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var shardStatus string
|
|
if err = a.db.QueryRowContext(ctx, "SELECT status FROM im_group_message_shards WHERE tenant_id=? AND shard_month=? AND table_name=?", id, msg.ShardMonth, table).Scan(&shardStatus); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return fmt.Errorf("mysql: group message shard %s is not registered", msg.ShardMonth)
|
|
}
|
|
return err
|
|
}
|
|
if shardStatus != "writable" && shardStatus != "sealing" {
|
|
return fmt.Errorf("mysql: group message shard %s is %s", msg.ShardMonth, shardStatus)
|
|
}
|
|
clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""}
|
|
contentJSON := common.ToJSON(msg.Content)
|
|
tx, err := a.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
messageID := store.DecodeUid(msg.Uid())
|
|
if clientMsgID.Valid {
|
|
if _, err = tx.ExecContext(ctx, `INSERT INTO im_group_message_dedup
|
|
(tenant_id,topic,from_user,client_msg_id,seqid,shard_month,message_id,status,expires_at)
|
|
VALUES(?,?,?,?,?,?,?,'done',?)`, id, msg.Topic, decodeUID(t.ParseUid(msg.From)), clientMsgID,
|
|
msg.SeqId, msg.ShardMonth, messageID, time.Now().UTC().Add(30*24*time.Hour)); err != nil {
|
|
if isDupe(err) {
|
|
return t.ErrDuplicate
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
query := fmt.Sprintf(`INSERT INTO %s
|
|
(tenant_id,message_id,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,content_nonce,key_version)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, table)
|
|
_, err = tx.ExecContext(ctx, query,
|
|
id, messageID, 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, msg.ContentNonce, msg.KeyVersion)
|
|
if isDupe(err) {
|
|
return t.ErrDuplicate
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `INSERT INTO im_group_message_topic_shards
|
|
(tenant_id,topic,shard_month,min_seq,max_seq,message_count,min_created_at,max_created_at)
|
|
VALUES(?,?,?,?,?,1,?,?) ON DUPLICATE KEY UPDATE
|
|
min_seq=LEAST(min_seq,VALUES(min_seq)),max_seq=GREATEST(max_seq,VALUES(max_seq)),
|
|
message_count=message_count+1,min_created_at=LEAST(min_created_at,VALUES(min_created_at)),
|
|
max_created_at=GREATEST(max_created_at,VALUES(max_created_at))`,
|
|
id, msg.Topic, msg.ShardMonth, msg.SeqId, msg.SeqId, msg.CreatedAt, msg.CreatedAt); err != nil {
|
|
return err
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `UPDATE im_group_message_shards SET row_count=row_count+1,
|
|
min_created_at=IF(min_created_at IS NULL,?,LEAST(min_created_at,?)),
|
|
max_created_at=IF(max_created_at IS NULL,?,GREATEST(max_created_at,?)) WHERE tenant_id=? AND shard_month=?`,
|
|
msg.CreatedAt, msg.CreatedAt, msg.CreatedAt, msg.CreatedAt, id, msg.ShardMonth); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
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,content_nonce,key_version "+
|
|
fmt.Sprintf("FROM %s WHERE tenant_id=? AND `from`=? AND client_msg_id=?", tenantMessagesTable(id)),
|
|
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()
|
|
}
|
|
var seq int64
|
|
if err = a.db.QueryRowContext(ctx, `SELECT seqid FROM im_group_message_dedup
|
|
WHERE tenant_id=? AND topic=? AND from_user=? AND client_msg_id=?`, id, topic, decodeUID(from), clientMsgID).Scan(&seq); err == nil {
|
|
msg, getErr := tenantGroupMessageGetOne(a.db, ctx, id, topic, seq, "")
|
|
if getErr == nil {
|
|
return msg, nil
|
|
}
|
|
if getErr != sql.ErrNoRows {
|
|
return nil, getErr
|
|
}
|
|
} else if err != sql.ErrNoRows {
|
|
return nil, err
|
|
}
|
|
tables, err := groupMessageTablesForTopic(a.db, ctx, id, topic)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, table := range tables {
|
|
month := groupMessageMonthFromTable(table)
|
|
idColumn := "id"
|
|
if month != "" {
|
|
idColumn = "message_id"
|
|
}
|
|
row := struct {
|
|
t.Message
|
|
RecalledBy sql.NullInt64 `db:"recalled_by"`
|
|
}{}
|
|
query := fmt.Sprintf("SELECT %s AS 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,content_nonce,key_version,'%s' AS shard_month FROM %s WHERE tenant_id=? AND topic=? AND from_user=? AND client_msg_id=?", idColumn, month, table)
|
|
err = a.db.QueryRowxContext(ctx, query, id, topic, decodeUID(from), clientMsgID).StructScan(&row)
|
|
if err == nil {
|
|
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
|
|
return &msg, nil
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
return nil, err
|
|
}
|
|
}
|
|
return nil, 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,
|
|
fmt.Sprintf("UPDATE %s SET updatedat=?,version=version+1,recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE tenant_id=? AND topic=? AND seqid=? AND version=?", tenantMessagesTable(id)),
|
|
recalledAt, recalledAt, decodeUID(actor), id, topic, seq, expectedVersion)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, recalledAt); 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, messageTable, err := tenantGroupMessageGetOneWithTable(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, fmt.Sprintf(
|
|
"UPDATE %s SET updated_at=?,version=version+1,status='recalled',recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE tenant_id=? AND topic=? AND seqid=? AND version=?", messageTable),
|
|
recalledAt, recalledAt, decodeUID(actor), id, topic, seq, expectedVersion)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, recalledAt); 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(tenant_id,createdat,updatedat,topic,seqid,version,actor,head,content,content_nonce,key_version,reason) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
id, editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), msg.ContentNonce, msg.KeyVersion, strings.TrimSpace(reason)); err != nil {
|
|
return nil, err
|
|
}
|
|
storedContent, nonce, keyVersion := messageStoredContent(content)
|
|
_, err = tx.ExecContext(ctx,
|
|
fmt.Sprintf("UPDATE %s SET updatedat=?,version=version+1,head=?,content=?,content_nonce=?,key_version=? WHERE tenant_id=? AND topic=? AND seqid=? AND version=?", tenantMessagesTable(id)),
|
|
editedAt, head, common.ToJSON(storedContent), nonce, keyVersion, id, topic, seq, expectedVersion)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, editedAt); 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, messageTable, err := tenantGroupMessageGetOneWithTable(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(tenant_id,createdat,updatedat,topic,seqid,version,actor,head,content,content_nonce,key_version,reason) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
id, editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), msg.ContentNonce, msg.KeyVersion, strings.TrimSpace(reason)); err != nil {
|
|
return nil, err
|
|
}
|
|
storedContent, nonce, keyVersion := messageStoredContent(content)
|
|
contentJSON := common.ToJSON(storedContent)
|
|
_, err = tx.ExecContext(ctx, fmt.Sprintf(
|
|
"UPDATE %s SET updated_at=?,version=version+1,msg_type=?,head=?,content=?,content_nonce=?,key_version=?,content_size=? WHERE tenant_id=? AND topic=? AND seqid=? AND version=?", messageTable),
|
|
editedAt, messageHeadType(head), head, contentJSON, nonce, keyVersion, len(contentJSON), id, topic, seq, expectedVersion)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, editedAt); 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 {
|
|
return tenantAdp.TenantMessageGetAll(id, topic, uid, opts)
|
|
}
|
|
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,m.content_nonce,m.key_version "+
|
|
fmt.Sprintf("FROM %s AS m LEFT JOIN im_dellog AS d ON d.tenant_id=m.tenant_id AND d.topic=m.topic ", tenantMessagesTable(id))+
|
|
"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
|
|
seqClause := ""
|
|
seqArgs := make([]any, 0)
|
|
if opts != nil {
|
|
seqClause = "AND m.seqid "
|
|
if len(opts.IdRanges) > 0 {
|
|
constr, addArgs := common.RangesToSql(opts.IdRanges)
|
|
seqClause += constr
|
|
seqArgs = append(seqArgs, addArgs...)
|
|
} else {
|
|
seqClause += "BETWEEN ? AND ?"
|
|
if opts.Since > 0 {
|
|
seqArgs = append(seqArgs, opts.Since)
|
|
} else {
|
|
seqArgs = append(seqArgs, 0)
|
|
}
|
|
if opts.Before > 1 {
|
|
seqArgs = append(seqArgs, opts.Before-1)
|
|
} else {
|
|
seqArgs = append(seqArgs, int64(^uint64(0)>>1))
|
|
}
|
|
}
|
|
if opts.Limit > 0 && opts.Limit < limit {
|
|
limit = opts.Limit
|
|
}
|
|
}
|
|
ctx, cancel := a.getContext()
|
|
if cancel != nil {
|
|
defer cancel()
|
|
}
|
|
tables, err := groupMessageTablesForTopic(a.db, ctx, id, topic)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
msgs := make([]t.Message, 0, limit)
|
|
seen := make(map[int64]struct{}, limit)
|
|
for _, table := range tables {
|
|
if len(msgs) >= limit {
|
|
break
|
|
}
|
|
month := groupMessageMonthFromTable(table)
|
|
idColumn := "id"
|
|
if month != "" {
|
|
idColumn = "message_id"
|
|
}
|
|
args := []any{id, store.DecodeUid(uid), id, topic}
|
|
args = append(args, seqArgs...)
|
|
args = append(args, limit-len(msgs))
|
|
query := fmt.Sprintf("SELECT m.%s AS 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,m.content_nonce,m.key_version,'%s' AS shard_month FROM %s AS m LEFT JOIN im_dellog AS d ON d.tenant_id=? AND d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE m.tenant_id=? AND m.status!='deleted' AND m.topic=? %s AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", idColumn, month, table, seqClause)
|
|
rows, queryErr := a.db.QueryxContext(ctx, query, args...)
|
|
if queryErr != nil {
|
|
return nil, queryErr
|
|
}
|
|
for rows.Next() {
|
|
row := struct {
|
|
t.Message
|
|
RecalledBy sql.NullInt64 `db:"recalled_by"`
|
|
}{}
|
|
if err = rows.StructScan(&row); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
if _, exists := seen[row.SeqId]; exists {
|
|
continue
|
|
}
|
|
seen[row.SeqId] = struct{}{}
|
|
msgs = append(msgs, normalizeGroupMessage(id, row.Message, row.RecalledBy))
|
|
}
|
|
if err = rows.Err(); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
rows.Close()
|
|
}
|
|
sort.Slice(msgs, func(i, j int) bool { return msgs[i].SeqId > msgs[j].SeqId })
|
|
if len(msgs) > limit {
|
|
msgs = msgs[:limit]
|
|
}
|
|
return msgs, nil
|
|
}
|
|
|
|
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.tenant_id=?", "m.topic=?", "m.delid=0"}
|
|
args := []any{id, 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 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()
|
|
}
|
|
scanLimit := messageSearchScanLimit(limit, queryText)
|
|
queryArgs := append([]any{id, decodeUID(uid)}, args...)
|
|
queryArgs = append(queryArgs, scanLimit)
|
|
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,m.content_nonce,m.key_version "+
|
|
fmt.Sprintf("FROM %s AS m LEFT JOIN im_dellog AS d ON d.tenant_id=? AND d.topic=m.topic ", tenantMessagesTable(id))+
|
|
"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)
|
|
scanned := 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)
|
|
scanned++
|
|
lastSeq = msg.SeqId
|
|
if !messageMatchesSearch(msg, queryText) {
|
|
continue
|
|
}
|
|
if len(msgs) == limit {
|
|
lastSeq = msgs[len(msgs)-1].SeqId
|
|
nextCursor = strconv.FormatInt(lastSeq, 10)
|
|
break
|
|
}
|
|
msgs = append(msgs, msg)
|
|
}
|
|
if err = rows.Err(); err != nil {
|
|
return nil, "", err
|
|
}
|
|
if nextCursor == "" && scanned == scanLimit && lastSeq > 0 {
|
|
nextCursor = strconv.FormatInt(lastSeq, 10)
|
|
}
|
|
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.tenant_id=?", "m.status!='deleted'", "m.topic=?"}
|
|
args := []any{id, topic}
|
|
if cursor != "" {
|
|
parsed, err := strconv.ParseInt(cursor, 10, 64)
|
|
if err != nil || parsed <= 0 {
|
|
return nil, "", t.ErrMalformed
|
|
}
|
|
where = append(where, "m.seqid<?")
|
|
args = append(args, parsed)
|
|
}
|
|
queryText = strings.TrimSpace(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()
|
|
}
|
|
tables, err := groupMessageTablesForTopic(a.db, ctx, id, topic)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
scanLimit := messageSearchScanLimit(limit, queryText)
|
|
msgs := make([]t.Message, 0, scanLimit)
|
|
seen := make(map[int64]struct{}, scanLimit)
|
|
for _, table := range tables {
|
|
if len(seen) >= scanLimit {
|
|
break
|
|
}
|
|
month := groupMessageMonthFromTable(table)
|
|
idColumn := "id"
|
|
if month != "" {
|
|
idColumn = "message_id"
|
|
}
|
|
queryArgs := append([]any{id, store.DecodeUid(uid)}, args...)
|
|
queryArgs = append(queryArgs, scanLimit-len(seen))
|
|
query := fmt.Sprintf("SELECT m.%s AS 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,m.content_nonce,m.key_version,'%s' AS shard_month FROM %s AS m LEFT JOIN im_dellog AS d ON d.tenant_id=? AND d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE %s AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", idColumn, month, table, strings.Join(where, " AND "))
|
|
rows, queryErr := a.db.QueryxContext(ctx, query, queryArgs...)
|
|
if queryErr != nil {
|
|
return nil, "", queryErr
|
|
}
|
|
for rows.Next() {
|
|
row := struct {
|
|
t.Message
|
|
RecalledBy sql.NullInt64 `db:"recalled_by"`
|
|
}{}
|
|
if err = rows.StructScan(&row); err != nil {
|
|
rows.Close()
|
|
return nil, "", err
|
|
}
|
|
if _, exists := seen[row.SeqId]; exists {
|
|
continue
|
|
}
|
|
seen[row.SeqId] = struct{}{}
|
|
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
|
|
if messageMatchesSearch(msg, queryText) {
|
|
msgs = append(msgs, msg)
|
|
}
|
|
}
|
|
if err = rows.Err(); err != nil {
|
|
rows.Close()
|
|
return nil, "", err
|
|
}
|
|
rows.Close()
|
|
}
|
|
sort.Slice(msgs, func(i, j int) bool { return msgs[i].SeqId > msgs[j].SeqId })
|
|
nextCursor := ""
|
|
if len(msgs) > limit {
|
|
msgs = msgs[:limit]
|
|
lastSeq := msgs[len(msgs)-1].SeqId
|
|
nextCursor = strconv.FormatInt(lastSeq, 10)
|
|
} else if len(seen) >= scanLimit {
|
|
lastScanned := int64(0)
|
|
for seq := range seen {
|
|
if lastScanned == 0 || seq < lastScanned {
|
|
lastScanned = seq
|
|
}
|
|
}
|
|
if lastScanned > 0 {
|
|
nextCursor = strconv.FormatInt(lastScanned, 10)
|
|
}
|
|
}
|
|
return msgs, nextCursor, nil
|
|
}
|
|
|
|
func messageSearchScanLimit(limit int, queryText string) int {
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
if strings.TrimSpace(queryText) == "" {
|
|
return limit + 1
|
|
}
|
|
scanLimit := limit * 20
|
|
if scanLimit < 200 {
|
|
scanLimit = 200
|
|
}
|
|
if scanLimit > 1000 {
|
|
scanLimit = 1000
|
|
}
|
|
return scanLimit
|
|
}
|
|
|
|
func messageMatchesSearch(msg t.Message, queryText string) bool {
|
|
if msg.KeyVersion > 0 {
|
|
// Store decrypts and filters encrypted messages after the adapter returns.
|
|
return true
|
|
}
|
|
queryText = normalizeMessageSearchText(queryText)
|
|
if queryText == "" {
|
|
return true
|
|
}
|
|
text := normalizeMessageSearchText(messageSearchText(msg.Content))
|
|
if strings.Contains(text, queryText) {
|
|
return true
|
|
}
|
|
terms := strings.Fields(queryText)
|
|
if len(terms) == 0 {
|
|
return true
|
|
}
|
|
for _, term := range terms {
|
|
if !strings.Contains(text, term) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func messageSearchText(content any) string {
|
|
switch value := content.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
return value
|
|
case []byte:
|
|
var decoded any
|
|
if json.Unmarshal(value, &decoded) == nil {
|
|
return messageSearchText(decoded)
|
|
}
|
|
return string(value)
|
|
case map[string]any:
|
|
return messageSearchMapText(value)
|
|
case t.KVMap:
|
|
return messageSearchMapText(map[string]any(value))
|
|
default:
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return fmt.Sprint(value)
|
|
}
|
|
var decoded any
|
|
if json.Unmarshal(raw, &decoded) == nil && fmt.Sprintf("%T", decoded) != fmt.Sprintf("%T", value) {
|
|
return messageSearchText(decoded)
|
|
}
|
|
return string(raw)
|
|
}
|
|
}
|
|
|
|
func messageSearchMapText(value map[string]any) string {
|
|
parts := make([]string, 0, len(value))
|
|
for _, key := range []string{"text", "txt", "content", "title", "summary", "name", "file_name", "filename", "url"} {
|
|
if text := strings.TrimSpace(fmt.Sprint(value[key])); text != "" && text != "<nil>" {
|
|
parts = append(parts, text)
|
|
}
|
|
}
|
|
for _, key := range []string{"file", "attachment", "snapshot", "forward", "link", "card"} {
|
|
if nested, ok := value[key]; ok {
|
|
if text := strings.TrimSpace(messageSearchText(nested)); text != "" {
|
|
parts = append(parts, text)
|
|
}
|
|
}
|
|
}
|
|
if len(parts) == 0 {
|
|
raw, _ := json.Marshal(value)
|
|
return string(raw)
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func normalizeMessageSearchText(value string) string {
|
|
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(value)), " "))
|
|
}
|
|
|
|
func (a *adapter) TenantMessageMutations(id t.TenantID, topic string, uid t.Uid, sinceVersion int64, limit int) ([]t.MessageMutation, string, bool, int64, error) {
|
|
if !a.tenantScopedDB {
|
|
tenantAdp, err := a.routedTenantAdapter(id)
|
|
if err != nil {
|
|
return nil, "", false, 0, err
|
|
}
|
|
if tenantAdp != a {
|
|
return tenantAdp.TenantMessageMutations(id, topic, uid, sinceVersion, limit)
|
|
}
|
|
}
|
|
if err := validTenant(id); err != nil || topic == "" || uid.IsZero() || sinceVersion < 0 {
|
|
return nil, "", false, 0, t.ErrMalformed
|
|
}
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
ctx, cancel := a.getContext()
|
|
if cancel != nil {
|
|
defer cancel()
|
|
}
|
|
var currentVersion int64
|
|
if a.tenantScopedDB {
|
|
if err := a.db.GetContext(ctx, ¤tVersion, "SELECT IFNULL(mutation_version,0) FROM im_topics WHERE name=?", topic); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, "", false, 0, t.ErrNotFound
|
|
}
|
|
return nil, "", false, 0, err
|
|
}
|
|
} else {
|
|
if err := a.db.GetContext(ctx, ¤tVersion, "SELECT IFNULL(mutation_version,0) FROM im_topics WHERE tenant_id=? AND name=?", id, topic); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, "", false, 0, t.ErrNotFound
|
|
}
|
|
return nil, "", false, 0, err
|
|
}
|
|
}
|
|
if sinceVersion >= currentVersion {
|
|
return []t.MessageMutation{}, strconv.FormatInt(currentVersion, 10), false, currentVersion, nil
|
|
}
|
|
if t.GetTopicCat(topic) == t.TopicCatGrp {
|
|
return a.tenantGroupMessageMutations(ctx, id, topic, uid, limit, currentVersion)
|
|
}
|
|
return a.tenantP2PMessageMutations(ctx, id, topic, uid, limit, currentVersion)
|
|
}
|
|
|
|
func (a *adapter) tenantP2PMessageMutations(ctx context.Context, id t.TenantID, topic string, uid t.Uid, limit int, currentVersion int64) ([]t.MessageMutation, string, bool, int64, error) {
|
|
rows, err := a.db.QueryxContext(ctx,
|
|
"SELECT m.seqid,m.version,m.updatedat AS mutated_at,m.recalled_at,m.recalled_by,rev.actor "+
|
|
fmt.Sprintf("FROM %s AS m ", tenantMessagesTable(id))+
|
|
"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=? "+
|
|
"LEFT JOIN im_message_revisions AS rev ON rev.tenant_id=m.tenant_id AND rev.topic=m.topic AND rev.seqid=m.seqid AND rev.version=m.version-1 "+
|
|
"WHERE m.tenant_id=? AND m.topic=? AND d.deletedfor IS NULL AND (m.version>1 OR m.recalled_at IS NOT NULL OR m.deletedat IS NOT NULL) "+
|
|
"ORDER BY m.updatedat DESC,m.seqid DESC LIMIT ?",
|
|
decodeUID(uid), id, topic, limit+1)
|
|
if err != nil {
|
|
return nil, "", false, 0, err
|
|
}
|
|
defer rows.Close()
|
|
return scanTenantMessageMutations(rows, limit, currentVersion)
|
|
}
|
|
|
|
func (a *adapter) tenantGroupMessageMutations(ctx context.Context, id t.TenantID, topic string, uid t.Uid, limit int, currentVersion int64) ([]t.MessageMutation, string, bool, int64, error) {
|
|
tables, err := groupMessageTablesForTopic(a.db, ctx, id, topic)
|
|
if err != nil || len(tables) == 0 {
|
|
return nil, "", false, 0, err
|
|
}
|
|
parts := make([]string, 0, len(tables))
|
|
args := make([]any, 0, len(tables)*3+1)
|
|
for _, table := range tables {
|
|
parts = append(parts, "SELECT m.seqid,m.version,m.updated_at AS mutated_at,m.recalled_at,m.recalled_by,rev.actor "+
|
|
fmt.Sprintf("FROM %s AS m ", table)+
|
|
"LEFT JOIN im_dellog AS d ON d.tenant_id=m.tenant_id AND d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? "+
|
|
"LEFT JOIN im_message_revisions AS rev ON rev.tenant_id=m.tenant_id AND rev.topic=m.topic AND rev.seqid=m.seqid AND rev.version=m.version-1 "+
|
|
"WHERE m.tenant_id=? AND m.topic=? AND d.deletedfor IS NULL AND (m.version>1 OR m.recalled_at IS NOT NULL OR m.deleted_at IS NOT NULL OR m.status='recalled')")
|
|
args = append(args, decodeUID(uid), id, topic)
|
|
}
|
|
args = append(args, limit+1)
|
|
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM ("+strings.Join(parts, " UNION ALL ")+") AS mutations ORDER BY mutated_at DESC,seqid DESC LIMIT ?", args...)
|
|
if err != nil {
|
|
return nil, "", false, 0, err
|
|
}
|
|
defer rows.Close()
|
|
return scanTenantMessageMutations(rows, limit, currentVersion)
|
|
}
|
|
|
|
func scanTenantMessageMutations(rows *sqlx.Rows, limit int, currentVersion int64) ([]t.MessageMutation, string, bool, int64, error) {
|
|
items := make([]t.MessageMutation, 0, limit)
|
|
hasMore := false
|
|
for rows.Next() {
|
|
row := struct {
|
|
SeqId int64 `db:"seqid"`
|
|
Version int `db:"version"`
|
|
MutatedAt time.Time `db:"mutated_at"`
|
|
RecalledAt sql.NullTime `db:"recalled_at"`
|
|
RecalledBy sql.NullInt64 `db:"recalled_by"`
|
|
Actor sql.NullInt64 `db:"actor"`
|
|
}{}
|
|
if err := rows.StructScan(&row); err != nil {
|
|
return nil, "", false, 0, err
|
|
}
|
|
if len(items) == limit {
|
|
hasMore = true
|
|
continue
|
|
}
|
|
event := "message.edited"
|
|
operator := ""
|
|
if row.Actor.Valid {
|
|
operator = store.EncodeUid(row.Actor.Int64).String()
|
|
}
|
|
if row.RecalledAt.Valid {
|
|
event = "message.recalled"
|
|
if row.RecalledBy.Valid {
|
|
operator = store.EncodeUid(row.RecalledBy.Int64).String()
|
|
}
|
|
row.MutatedAt = row.RecalledAt.Time
|
|
}
|
|
items = append(items, t.MessageMutation{
|
|
SeqId: row.SeqId,
|
|
Version: row.Version,
|
|
Event: event,
|
|
Operator: operator,
|
|
MutatedAt: row.MutatedAt,
|
|
MutationVersion: currentVersion,
|
|
})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, "", false, 0, err
|
|
}
|
|
return items, strconv.FormatInt(currentVersion, 10), hasMore, currentVersion, 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=? AND finished_at IS NULL
|
|
AND NOT (status=? AND ? IN (?,?))`,
|
|
status, status, durationMS, finishedAt, finishedAt, callID, seq,
|
|
t.CallStatusAccepted, status, t.CallStatusMissed, t.CallStatusDeclined)
|
|
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) 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()
|
|
}
|
|
profile, err := a.userProfileByUID(ctx, id, uid)
|
|
if err == nil {
|
|
return profile, nil
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
return nil, err
|
|
}
|
|
if err := a.ensureUserProfile(ctx, id, uid); err != nil {
|
|
return nil, err
|
|
}
|
|
return a.userProfileByUID(ctx, id, uid)
|
|
}
|
|
|
|
func (a *adapter) ensureUserProfile(ctx context.Context, tenantID t.TenantID, uid t.Uid) error {
|
|
profileID, err := nextSnowflakeID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(tenant_id,id,user_id,public_account,friend_add_enabled,friend_add_policy_version) VALUES(?,?,?,?,?,?)`, tenantID, profileID, decodeUID(uid), uid.UserId(), true, 1); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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()
|
|
}
|
|
if err := a.ensureUserProfile(ctx, id, uid); 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, id, decodeUID(uid), patch.Version)
|
|
res, err := a.db.ExecContext(ctx, `UPDATE im_user_profiles SET `+strings.Join(sets, ",")+` WHERE tenant_id=? AND 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, id, 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()
|
|
}
|
|
privacy, err := a.userPrivacyByUID(ctx, id, uid)
|
|
if err == nil {
|
|
return privacy, nil
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
return nil, err
|
|
}
|
|
if err := a.ensureUserPrivacy(ctx, id, uid); err != nil {
|
|
return nil, err
|
|
}
|
|
return a.userPrivacyByUID(ctx, id, uid)
|
|
}
|
|
|
|
func (a *adapter) ensureUserPrivacy(ctx context.Context, tenantID t.TenantID, uid t.Uid) error {
|
|
privacyID, err := nextSnowflakeID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_privacy_settings(tenant_id,id,user_id,add_me_methods) VALUES(?,?,?,'{"account":true,"phone":true,"qr":true,"group":true}')`, tenantID, privacyID, decodeUID(uid)); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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()
|
|
}
|
|
if err := a.ensureUserPrivacy(ctx, id, 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.FriendRequestVerification != nil {
|
|
sets = append(sets, "friend_request_verification=?")
|
|
args = append(args, *patch.FriendRequestVerification)
|
|
}
|
|
if patch.GroupInviteVerification != nil {
|
|
sets = append(sets, "group_invite_verification=?")
|
|
args = append(args, *patch.GroupInviteVerification)
|
|
}
|
|
args = append(args, id, decodeUID(uid), patch.Version)
|
|
res, err := a.db.ExecContext(ctx, `UPDATE im_user_privacy_settings SET `+strings.Join(sets, ",")+` WHERE tenant_id=? AND 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, id, 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))
|
|
token.SDKVersion = strings.TrimSpace(token.SDKVersion)
|
|
token.PushEnv = strings.ToLower(strings.TrimSpace(token.PushEnv))
|
|
token.NotificationPermission = strings.ToLower(strings.TrimSpace(token.NotificationPermission))
|
|
tokenID, err := nextSnowflakeID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, err = a.db.ExecContext(ctx,
|
|
`INSERT INTO im_device_push_tokens(tenant_id,id,device_id,user_id,provider,token,app_version,platform,sdk_version,push_env,notification_permission,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),sdk_version=VALUES(sdk_version),push_env=VALUES(push_env),
|
|
notification_permission=VALUES(notification_permission),updated_at=VALUES(updated_at)`,
|
|
id, tokenID, token.DeviceID, decodeUID(token.UserID), token.Provider, token.Token, token.AppVersion, token.Platform,
|
|
token.SDKVersion, token.PushEnv, token.NotificationPermission, 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 tenant_id=? AND provider IN (?) AND user_id IN (?)`, id, 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(tenant_id,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`,
|
|
id, 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, id, 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, id, 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 tenant_id=? AND challenge_id=? AND state=?`,
|
|
nextState, decision, strings.TrimSpace(actionTokenID), now, id, challengeID, t.RiskChallengeStatePending)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
tx = nil
|
|
return a.riskChallengeByID(ctx, id, 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(tenant_id,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`,
|
|
id, 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, id, 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(tenant_id,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`,
|
|
id, 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, id, feedback.UserID, idempotencyKey)
|
|
}
|
|
|
|
func (a *adapter) riskChallengeByID(ctx context.Context, tenantID t.TenantID, challengeID string) (*t.RiskChallenge, error) {
|
|
return a.riskChallengeByIDTx(ctx, a.db, tenantID, challengeID, false)
|
|
}
|
|
|
|
func (a *adapter) riskChallengeByIDTx(ctx context.Context, q sqlx.QueryerContext, tenantID t.TenantID, 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 tenant_id=? AND challenge_id=?`
|
|
if forUpdate {
|
|
query += " FOR UPDATE"
|
|
}
|
|
if err := sqlx.GetContext(ctx, q, &row, query, tenantID, 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, tenantID t.TenantID, 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 tenant_id=? AND reporter_id=? AND idempotency_key=?`,
|
|
tenantID, 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, tenantID t.TenantID, 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 tenant_id=? AND user_id=? AND idempotency_key=?`,
|
|
tenantID, 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, tenantID t.TenantID, 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"`
|
|
FriendAddEnabled bool `db:"friend_add_enabled"`
|
|
FriendAddDisabledReason string `db:"friend_add_disabled_reason"`
|
|
FriendAddPolicyVersion int `db:"friend_add_policy_version"`
|
|
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,friend_add_enabled,friend_add_disabled_reason,friend_add_policy_version,version,created_at,updated_at FROM im_user_profiles WHERE tenant_id=? AND user_id=?`, tenantID, 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,
|
|
FriendAddEnabled: row.FriendAddEnabled,
|
|
FriendAddDisabledReason: row.FriendAddDisabledReason,
|
|
FriendAddPolicyVersion: row.FriendAddPolicyVersion,
|
|
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, tenantID t.TenantID, 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"`
|
|
FriendRequestVerification bool `db:"friend_request_verification"`
|
|
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,friend_request_verification,group_invite_verification,version,created_at,updated_at FROM im_user_privacy_settings WHERE tenant_id=? AND user_id=?`, tenantID, 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,
|
|
FriendRequestVerification: row.FriendRequestVerification,
|
|
GroupInviteVerification: row.GroupInviteVerification,
|
|
Version: row.Version,
|
|
CreatedAt: row.CreatedAt,
|
|
UpdatedAt: row.UpdatedAt,
|
|
}
|
|
_ = json.Unmarshal(row.AddMeMethods, &settings.AddMeMethods)
|
|
return settings, nil
|
|
}
|
|
|
|
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,content_nonce,key_version "+
|
|
fmt.Sprintf("FROM %s WHERE tenant_id=? AND topic=? AND seqid=?", tenantMessagesTable(tenantID))+suffix,
|
|
tenantID, 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, tenantID t.TenantID, topic string) (int64, error) {
|
|
var groupID int64
|
|
err := a.db.QueryRowContext(ctx, "SELECT id FROM im_group_settings WHERE tenant_id=? AND topic=? LIMIT 1", tenantID, 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) {
|
|
msg, _, err := tenantGroupMessageGetOneWithTable(q, ctx, tenantID, topic, seq, suffix)
|
|
return msg, err
|
|
}
|
|
|
|
func tenantGroupMessageGetOneWithTable(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64, suffix string) (*t.Message, string, error) {
|
|
tables, err := groupMessageTablesForSeq(q, ctx, tenantID, topic, seq)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
for _, table := range tables {
|
|
month := groupMessageMonthFromTable(table)
|
|
idColumn := "id"
|
|
if month != "" {
|
|
idColumn = "message_id"
|
|
}
|
|
row := struct {
|
|
t.Message
|
|
RecalledBy sql.NullInt64 `db:"recalled_by"`
|
|
}{}
|
|
query := fmt.Sprintf("SELECT %s AS 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,content_nonce,key_version,'%s' AS shard_month FROM %s WHERE tenant_id=? AND topic=? AND seqid=?%s", idColumn, month, table, suffix)
|
|
err = sqlx.GetContext(ctx, q, &row, query, tenantID, topic, seq)
|
|
if err == nil {
|
|
msg := normalizeGroupMessage(tenantID, row.Message, row.RecalledBy)
|
|
return &msg, table, nil
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
return nil, "", err
|
|
}
|
|
}
|
|
return nil, "", sql.ErrNoRows
|
|
}
|
|
|
|
func messageVersion(version int) int {
|
|
if version <= 0 {
|
|
return 1
|
|
}
|
|
return version
|
|
}
|
|
|
|
func messageStoredContent(value any) (content any, nonce []byte, keyVersion int) {
|
|
if stored, ok := value.(t.StoredMessageContent); ok {
|
|
return stored.Content, stored.Nonce, stored.KeyVersion
|
|
}
|
|
return value, nil, 0
|
|
}
|
|
|
|
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, id, 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
|
|
(tenant_id,id,user_id,topic,pinned,muted,remark,message_retention,version,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
|
|
id, settingsID, decodeUID(uid), strings.TrimSpace(topic), false, false, "", "default", 1, now, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return a.conversationSettingsByKey(ctx, id, 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, id, 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 tenant_id=? AND 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, id, 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, id, 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
|
|
(tenant_id,id,user_id,topic,content,entities_json,version,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?)
|
|
ON DUPLICATE KEY UPDATE id=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, id, 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 tenant_id=? AND user_id=? AND topic=? AND version=?`,
|
|
content, entities, id, 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, id, uid, strings.TrimSpace(topic))
|
|
}
|
|
|
|
func (a *adapter) conversationSettingsByKey(ctx context.Context, tenantID t.TenantID, 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 tenant_id=? AND user_id=? AND topic=?`, tenantID, 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, tenantID t.TenantID, 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 tenant_id=? AND user_id=? AND topic=?`, tenantID, 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(tenant_id,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`,
|
|
id, 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{id, decodeUID(fav.UserID)}, args...)
|
|
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_favorites WHERE tenant_id=? AND 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{"tenant_id=?", "user_id=?"}
|
|
args := []any{id, 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 tenant_id=? AND user_id=? AND favorite_id=?", 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 tenant_id=? AND user_id=? AND favorite_id=?", 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.TenantMessageDeleteList(id, 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, id, 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 {
|
|
return tenantAdp.TenantMessageGetDeleted(id, topic, uid, opts)
|
|
}
|
|
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.TenantDeviceUpsert(id, 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 {
|
|
return tenantAdp.TenantDeviceGetAll(id, uids...)
|
|
}
|
|
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.TenantDeviceDelete(id, 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.TenantFileStartUpload(id, 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 {
|
|
return tenantAdp.TenantFileFinishUpload(id, fd, success, size)
|
|
}
|
|
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 {
|
|
return tenantAdp.TenantFileGet(id, fid)
|
|
}
|
|
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.TenantFileDeleteUnused(id, 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.TenantFileLinkAttachments(id, 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.TenantFileUserHasAccess(id, 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, fmt.Sprintf(`
|
|
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 %s 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 %s 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`, tenantFriendshipsTable(id), tenantMessagesTable(id)),
|
|
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 tenant_id=? AND scene=? AND phone_hash=? AND state=?`,
|
|
t.VerificationStateExpired, now, id, 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
|
|
(tenant_id,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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
id, 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 tenant_id=? AND challenge_id=? LIMIT 1`, id, 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 tenant_id=? AND scene=? AND phone_hash=? AND state=? AND expires_at>?
|
|
ORDER BY created_at DESC LIMIT 1`, id, 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 tenant_id=? AND challenge_id=?`,
|
|
state, now, id, 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 tenant_id=? AND challenge_id=? AND state=?`,
|
|
t.VerificationStateVerified, tokenHash, verifiedAt, verifiedAt, id, 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 tenant_id=? AND challenge_id=? AND verification_token_hash=? AND state=? AND expires_at>?`,
|
|
t.VerificationStateConsumed, consumedAt, consumedAt, id, 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)
|
|
var version int
|
|
err := a.db.GetContext(ctx, &version,
|
|
`SELECT auth_version FROM im_user_security WHERE tenant_id=? AND user_id=?`,
|
|
id, decoded)
|
|
if err == nil {
|
|
return version, nil
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
return 0, err
|
|
}
|
|
|
|
now := t.TimeNow()
|
|
securityID, err := nextSnowflakeID()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
_, err = a.db.ExecContext(ctx,
|
|
`INSERT INTO im_user_security(tenant_id,id,user_id,auth_version,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?)
|
|
ON DUPLICATE KEY UPDATE auth_version=auth_version`,
|
|
id, securityID, decoded, 1, now, now)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
err = a.db.GetContext(ctx, &version,
|
|
`SELECT auth_version FROM im_user_security WHERE tenant_id=? AND user_id=?`,
|
|
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(tenant_id,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)`,
|
|
id, 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 tenant_id=? AND user_id=?`,
|
|
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 tenant_id=? AND user_id=?
|
|
ORDER BY created_at DESC,id DESC LIMIT 1`,
|
|
id, 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
|
|
(tenant_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)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
id, 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
|
|
(tenant_id,id,appeal_id,user_id,reason,attachments_json,status,review_comment,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
|
id, 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
|
|
(tenant_id,user_id,token_id,action,target,token_hash,state,expires_at,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
|
id, 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 tenant_id=? AND user_id=? AND token_id=? AND action=? AND target=? LIMIT 1`,
|
|
id, 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 tenant_id=? AND user_id=? AND token_id=? AND action=? AND target=? AND state=0 AND expires_at>?`,
|
|
now, now, id, 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 tenant_id=? AND user_id=? AND purpose=?`,
|
|
id, 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
|
|
(tenant_id,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)`,
|
|
id, 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 tenant_id=? AND user_id=? AND purpose=?`,
|
|
attempts, lockedUntil, updatedAt, id, 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, id, uid)
|
|
if err == sql.ErrNoRows {
|
|
return defaultYouthModeSettings(id, 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 tenant_id=? AND user_id=? AND purpose=? AND LENGTH(password_hash)>0`,
|
|
enabled, policyVersion, enabledAt, updatedAt, id, decodeUID(uid), "youth_mode")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
affected, _ := res.RowsAffected()
|
|
if affected == 0 {
|
|
return nil, t.ErrMalformed
|
|
}
|
|
return a.youthModeByUID(ctx, id, uid)
|
|
}
|
|
|
|
func (a *adapter) youthModeByUID(ctx context.Context, tenantID t.TenantID, 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 tenant_id=? AND user_id=? AND purpose=?`, tenantID, 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(tenantID t.TenantID, 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
|
|
(tenant_id,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)`,
|
|
id, 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 tenant_id=? AND user_id=?`
|
|
args := []any{id, 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 tenant_id=? AND user_id=? AND read_at IS NULL`,
|
|
id, 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 tenant_id=? AND user_id=? AND read_at IS NULL AND event_type LIKE ?`,
|
|
id, 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=?,updated_at=?
|
|
WHERE tenant_id=? AND user_id=? AND event_id=?`,
|
|
readAt, readAt, id, 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(tenant_id,request_id,applicant_id,target_id,message,source,source_topic,status,idempotency_key,expires_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
id, 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, id, 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{"fr.tenant_id=?"}
|
|
args := []any{id}
|
|
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.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
|
|
FROM im_friend_requests AS fr
|
|
JOIN im_users AS u ON u.tenant_id=fr.tenant_id AND u.id=IF(fr.applicant_id=?,fr.target_id,fr.applicant_id)
|
|
LEFT JOIN im_user_profiles AS p ON p.tenant_id=u.tenant_id AND p.user_id=u.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"`
|
|
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"`
|
|
}{}
|
|
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: profilePublicFromFields(row.ProfileNickname, row.ProfileAvatarFileID, row.ProfileSignature, row.ProfileGender, row.ProfileRegion, row.ProfilePublicAccount),
|
|
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 tenant_id=? AND target_id=? AND status=? AND expires_at>?`,
|
|
id, 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 tenant_id=? AND ((applicant_id=? AND target_id=?) OR (applicant_id=? AND target_id=?)) ORDER BY created_at DESC,id DESC LIMIT 1",
|
|
id, 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 tenant_id=? AND request_id=? FOR UPDATE", id, 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 tenant_id=? AND request_id=? AND status=?",
|
|
t.FriendRequestAccepted, handledAt, handledAt, id, 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 tenant_id=? AND request_id<>? AND status=? AND
|
|
((applicant_id=? AND target_id=?) OR (applicant_id=? AND target_id=?))`,
|
|
t.FriendRequestExpired, handledAt, handledAt, id, 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,
|
|
fmt.Sprintf("INSERT INTO %s(tenant_id,id,user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ", tenantFriendshipsTable(id))+
|
|
"ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)",
|
|
id, 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 tenant_id=? AND name=?",
|
|
handledAt, handledAt, t.StateOK, id, 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, id, 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 tenant_id=? AND request_id=? AND status=?",
|
|
status, handledAt, handledAt, id, 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, id, 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,
|
|
fmt.Sprintf("INSERT INTO %s(tenant_id,id,user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ", tenantFriendshipsTable(id))+
|
|
"ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)",
|
|
id, 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.tenant_id=?", "fs.user_id=?", "fs.status=?", "fs.peer_id<>?"}
|
|
args := []any{id, 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 ?)")
|
|
args = append(args, like, like, like)
|
|
}
|
|
if initial = strings.TrimSpace(initial); initial != "" {
|
|
like := initial + "%"
|
|
where = append(where, "(fs.remark LIKE ? OR p.nickname LIKE ?)")
|
|
args = append(args, like, like)
|
|
}
|
|
args = append(args, limit+1)
|
|
ctx, cancel := a.getContext()
|
|
if cancel != nil {
|
|
defer cancel()
|
|
}
|
|
rows, err := a.db.QueryxContext(ctx, fmt.Sprintf(`SELECT fs.*,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 %s AS fs
|
|
JOIN im_users AS u ON u.tenant_id=fs.tenant_id AND u.id=fs.peer_id
|
|
LEFT JOIN im_user_profiles AS p ON p.tenant_id=u.tenant_id AND p.user_id=u.id
|
|
WHERE `+strings.Join(where, " AND ")+`
|
|
ORDER BY fs.peer_id ASC LIMIT ?`, tenantFriendshipsTable(id)), 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"`
|
|
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(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, fmt.Sprintf(`SELECT user_id,peer_id,status,remark,topic,created_at,updated_at
|
|
FROM %s WHERE tenant_id=? AND user_id=? AND peer_id=?`, tenantFriendshipsTable(id)),
|
|
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,
|
|
fmt.Sprintf("UPDATE %s SET remark=?,updated_at=? WHERE tenant_id=? AND user_id=? AND peer_id=? AND status=?", tenantFriendshipsTable(id)),
|
|
remark, now, id, 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,
|
|
fmt.Sprintf("UPDATE %s SET status=?,updated_at=? WHERE tenant_id=? AND ((user_id=? AND peer_id=?) OR (user_id=? AND peer_id=?))", tenantFriendshipsTable(id)),
|
|
t.RelationshipNone, now, id, 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 tenant_id=? AND blocker_id=? AND blocked_id=?`,
|
|
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.tenant_id=?", "b.blocker_id=?"}
|
|
args := []any{id, 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.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
|
|
FROM im_user_blocks AS b
|
|
LEFT JOIN im_users AS u ON u.tenant_id=b.tenant_id AND u.id=b.blocked_id
|
|
LEFT JOIN im_user_profiles AS p ON p.tenant_id=b.tenant_id AND p.user_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"`
|
|
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"`
|
|
}{}
|
|
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: profilePublicFromFields(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) 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(tenant_id,id,blocker_id,blocked_id,created_at,updated_at) VALUES(?,?,?,?,?,?) "+
|
|
"ON DUPLICATE KEY UPDATE updated_at=VALUES(updated_at)",
|
|
id, 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 tenant_id=? AND blocker_id=? AND blocked_id=?",
|
|
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 u.id=?)")
|
|
args = append(args, like, like, like, queryText, phoneCandidate, 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, fmt.Sprintf(`(EXISTS (
|
|
SELECT 1 FROM %s 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')`, tenantFriendshipsTable(id)))
|
|
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.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 tenant_id=? AND request_id=?", tenantID, 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 tenant_id=? AND applicant_id=? AND idempotency_key=?", tenantID, 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))
|
|
}
|