Files
im_webapp/src/lib/im-api.js
T
Your Name 4fc65b0f40 fix(日报): "提醒未提交成员"改用组织架构接口凑候选名单,不再必现400
后端问题清单"新增2"记录过:POST /reports/daily/reminders 要求调用方传入完整候选
user_ids 名单(服务端只做"从候选里筛出未交的人再提醒",不会自己算全公司名单),
但前端一直传空数组,必然命中服务端"user_ids为空"的400校验。

重新核实时发现这条其实不是后端缺口——GET /organization/members 不传 q/department_id
时就会返回调用者可见范围内的全部在职成员(分页),这个接口本来就在会议参会人选择器
里用着,只是"提醒未提交成员"这个按钮没有复用它。改为点击时先分页拉取全部组织成员
user_id,再传给提醒接口;组织架构为空时给出明确提示而不是让请求再次落入400。

已在浏览器验证请求格式(limit=100,与后端实际接受的参数一致)和空组织架构下的
兜底提示;当前测试租户组织架构数据本身为空,未能覆盖"真的有人被提醒"这条路径,
但接口契约已对照 im_be 源码逐字段核实。
2026-09-06 12:07:24 +08:00

837 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Thin REST client for the product-specific `/v1/im/*` API that sits alongside the Tinode WS protocol.
// Endpoint shapes verified directly against the deployed backend (im_team/im_server, server/im_http.go).
import { API_KEY } from '../config.js';
import { PACKAGE_VERSION } from '../version.js';
import LocalStorageUtil from './local-storage.js';
const DEFAULT_API_BASE = '/v1/im';
const DEFAULT_FILE_BASE = '/v0/file';
export class ImApiError extends Error {
constructor(message, reason, status, details) {
super(message);
this.reason = reason;
this.status = status;
this.details = details;
}
}
// docs/IM-前后端对接文档.md §12.4's full reason list. Every existing call site already does
// `catch(err => onToast(err.message || '...'))`, so translating known reasons at the one place the
// error is constructed fixes all of them at once, rather than needing a matching change at every
// individual catch block scattered across the app.
const REASON_MESSAGES = {
// Real per-account risk is low (this app always sends a hardcoded key, so hitting these means a
// deployment-wide config problem, not something an individual user did) -- but exactly this "the
// whole site is silently misconfigured" scenario has genuinely happened once already (a stale API
// key got redeployed to production), so it's worth a real message instead of raw English site-wide.
API_KEY_REQUIRED: '服务暂不可用,请刷新页面重试',
METHOD_NOT_ALLOWED: '请求方式有误,请刷新页面重试',
TENANT_UNAVAILABLE: '企业码不存在或已停用',
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
AUTH_FAILED: '账号或密码错误',
TOKEN_EXPIRED: '登录已过期,请重新登录',
ACCOUNT_DISABLED: '账号已被禁用',
RATE_LIMITED: '操作过于频繁,请稍后再试',
VALIDATION_FAILED: '提交的内容不符合要求',
IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试',
RESOURCE_NOT_FOUND: '内容不存在或已被删除',
// 后端 server/im_http.go 里 30+ 处 403 用的都是 imReasonPermissionDenied="PERMISSION_DENIED",
// 从未发送过字面量 "FORBIDDEN"——原来的 FORBIDDEN 条目是死代码,全站 403 一直透传英文原文。
PERMISSION_DENIED: '没有权限执行此操作',
NOT_FOUND: '内容不存在或已被删除',
VERSION_CONFLICT: '内容已被修改,请刷新后重试',
MALFORMED_REQUEST: '请求内容有误,请稍后重试',
AUTH_REQUIRED: '请先登录',
ACTION_TOKEN_INVALID: '操作凭证已失效,请重新操作',
SCHEMA_UNSUPPORTED: '当前版本过旧,请刷新页面后重试',
IDEMPOTENCY_IN_PROGRESS: '上一次操作正在处理中,请稍后再试',
INTERNAL_ERROR: '服务器繁忙,请稍后重试',
UNSUPPORTED_MEDIA_TYPE: '不支持的文件类型',
CHALLENGE_EXPIRED: '验证码已过期,请重新获取',
SMS_DELIVERY_FAILED: '短信发送失败,请稍后重试',
PASSWORD_POLICY_VIOLATION: '密码不符合安全要求',
VERIFICATION_RATE_LIMITED: details => details && details.retry_after_seconds
? `操作过于频繁,请 ${details.retry_after_seconds} 秒后重试`
: '操作过于频繁,请稍后再试',
ALREADY_GROUP_MEMBER: '对方已经是群成员',
DESCRIPTION_REQUIRED: '请填写简介内容',
DESCRIPTION_TOO_LONG: '简介内容过长',
GROUP_ADMIN_LIMIT_EXCEEDED: '群管理员数量已达上限',
INVALID_CREDIT_CODE: '统一社会信用代码格式不正确',
INVALID_EMAIL: '邮箱格式不正确',
MAILBOX_UNAVAILABLE: '总经理信箱暂不可用',
TOO_MANY_FILES: '文件数量超出限制',
FRIEND_RELATION_REQUIRED: '需要先添加对方为好友',
MESSAGE_NOT_ALLOWED: '当前状态无法发送消息',
GROUP_MUTED: '你已被禁言,无法发言',
GROUP_MEMBER_LIMIT_EXCEEDED: '群成员数量已达上限',
TENANT_USER_QUOTA_EXCEEDED: '企业账号数已达上限',
TENANT_GROUP_QUOTA_EXCEEDED: '企业群组数已达上限',
FILE_TOO_LARGE: '文件大小超出限制',
FILE_FORBIDDEN: '没有权限访问此文件',
FILE_UNAVAILABLE: '文件暂不可用',
STORAGE_QUOTA_EXCEEDED: '存储空间已达上限',
REALNAME_REQUIRED: '需要先完成实名认证',
YOUTH_MODE_RESTRICTED: '青少年模式下无法使用此功能',
RISK_CONFIRMATION_REQUIRED: '需要先完成安全确认',
RISK_ACTION_BLOCKED: '该操作已被安全策略拦截',
// CODE_INVALID is shared by two unrelated verify endpoints -- independent-password PIN check
// (青少年模式关闭/开启走的这条接口,对接文档 809 行) *and* the plain SMS verification-code
// verify endpoints (/auth/verification-codes/{id}/verify, /security/challenges/{id}/verify --
// confirmed live: entering a wrong SMS code on the "忘记独立密码?" identity-check screen also
// gets back reason=CODE_INVALID). This table only keys off the reason string with no per-call-site
// override, so a message written for just one of the two ("独立密码不正确") showed up verbatim on
// the *other* endpoint's screen, where there's no "独立密码" concept in view at all -- confusing,
// wrong-sounding copy for a real, correctly-rejected wrong code. Kept generic enough to be accurate
// for both rather than wrong in either.
CODE_INVALID: '验证码或独立密码不正确',
TOO_MANY_ATTEMPTS: details => details && details.retry_after_seconds
? `连续输错次数过多,请 ${details.retry_after_seconds} 秒后重试`
: '连续输错次数过多,请稍后重试'
};
function requestId() {
return (typeof crypto != 'undefined' && crypto.randomUUID) ? crypto.randomUUID() :
'web_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2);
}
// Tenant context persists in localStorage (not sessionStorage): it must survive browser restarts
// the same way the auth-token does, so a returning user's auto-login can still supply `hi.tenant`.
export function getTenantContext() {
return LocalStorageUtil.getObject('im-tenant');
}
export function currentTenantCode() {
const tenant = getTenantContext();
return (tenant && tenant.code) || 'default';
}
function apiBase() {
const tenant = getTenantContext();
return ((tenant && tenant.endpoints && tenant.endpoints.api) || DEFAULT_API_BASE).replace(/\/$/, '');
}
function fileBase() {
const tenant = getTenantContext();
return ((tenant && tenant.endpoints && tenant.endpoints.file) || DEFAULT_FILE_BASE).replace(/\/$/, '');
}
// tinode-web.jsx always sets this on login/reconnect (sessionStorage, tab-lifetime), independent of
// whether the user opted into "keep logged in" (which only controls the separate localStorage token
// used for surviving a browser restart). REST calls need a token for the current session regardless.
function authToken() {
return sessionStorage.getItem('im-session-token') || '';
}
function normalizeResponse(payload) {
return (payload && payload.code === 0 && payload.data !== undefined) ? payload.data : payload;
}
// Core request helper. `auth: false` skips the Authorization header (pre-login calls).
export async function imRequest(path, {method = 'GET', body, headers, auth = true, tenantCodeOverride} = {}) {
const finalHeaders = {
'Content-Type': 'application/json',
'X-MeetingIM-APIKey': API_KEY,
'X-IM-Schema': '1',
'X-Tenant-Code': tenantCodeOverride || currentTenantCode(),
'X-Request-ID': requestId(),
...headers
};
if (auth) {
const token = authToken();
if (token) {
finalHeaders.Authorization = 'Bearer ' + token;
}
}
const response = await fetch(apiBase() + path, {
method,
headers: finalHeaders,
body: body !== undefined ? JSON.stringify(body) : undefined
});
const text = await response.text();
let payload = {};
if (text) {
try {
payload = JSON.parse(text);
} catch (e) {
throw new ImApiError(response.ok ? '接口返回非 JSON 数据' : (response.statusText || '接口请求失败'),
'NON_JSON_RESPONSE', response.status, text.slice(0, 160));
}
}
if (!response.ok || (payload && payload.code && payload.code !== 0)) {
// A known reason always wins over the raw backend message -- gives every caller (they all just do
// `err.message || '...'`) the doc-mandated, consistently-worded text instead of whatever string
// that particular backend handler happened to write, and instead of duplicating this mapping at
// every individual call site. An unrecognized reason still falls back to the backend's own message
// (not silently replaced with a generic string) since this backend's messages have consistently
// been reasonable, specific, business-appropriate text in real testing, not raw internals -- only
// reasons this app doesn't otherwise know how to react to fall through to it.
const known = translateReason(payload.reason, payload.details);
throw new ImApiError(known || payload.message || payload.reason || response.statusText || '请求失败',
payload.reason, response.status, payload.details);
}
return normalizeResponse(payload);
}
// Several batch endpoints (favorites/batch, favorites/forward, messages/forward, ...) always return
// HTTP 200 and bury the real per-item outcome in a nested `items[]`/`failed[]` array instead of
// throwing -- imRequest's translation above never runs for those, since it only fires on a non-2xx/
// non-zero-code response. Exported so call sites reading one of those per-item `reason` strings
// directly (e.g. send-message.jsx's sendFavorite) can reuse the exact same table instead of either
// duplicating it or -- as was actually happening -- showing the raw untranslated reason string.
export function translateReason(reason, details) {
const entry = reason && REASON_MESSAGES[reason];
return (typeof entry === 'function' ? entry(details) : entry) || null;
}
const idem = () => ({'Idempotency-Key': requestId()});
// Most list endpoints here cap out at 20-50 items per page (some as low as 30, e.g. group
// members against a 128-member cap) and round7 testing found several call sites only ever
// fetched the first page -- for things like "am I this group's owner/admin" or the admin
// candidate list, a real owner/admin sitting past page 1 went undetected entirely rather than
// just showing an incomplete list. Empty/repeated next_cursor means done; this app's list
// responses haven't been observed to send a separate has_more that disagrees with that.
export async function fetchAllPages(fetchPage, startCursor = '') {
let cursor = startCursor;
let items = [];
for (;;) {
const page = await fetchPage(cursor);
items = items.concat((page && page.items) || []);
const nextCursor = (page && page.next_cursor) || '';
if (!nextCursor || nextCursor === cursor) {
break;
}
cursor = nextCursor;
}
return items;
}
export const imGet = (path, opts) => imRequest(path, opts);
export const imPost = (path, body, opts = {}) => imRequest(path, {method: 'POST', body, headers: idem(), ...opts});
export const imPatch = (path, body, opts = {}) => imRequest(path, {method: 'PATCH', body, headers: idem(), ...opts});
export const imPut = (path, body, opts = {}) => imRequest(path, {method: 'PUT', body, headers: idem(), ...opts});
export const imDelete = (path, opts = {}) => imRequest(path, {method: 'DELETE', headers: idem(), ...opts});
function qs(params) {
const usp = new URLSearchParams();
Object.entries(params || {}).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') {
usp.set(k, v);
}
});
const s = usp.toString();
return s ? '?' + s : '';
}
// -- Public uploads (multipart; used before login and for standalone attachments not tied to a chat message). --
export async function uploadPublicFile(file, purpose, onProgress) {
const form = new FormData();
form.append('purpose', purpose);
form.append('file', file);
// Use XHR instead of fetch to get upload progress events.
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', apiBase() + '/public/uploads');
xhr.setRequestHeader('X-MeetingIM-APIKey', API_KEY);
xhr.setRequestHeader('X-IM-Schema', '1');
xhr.setRequestHeader('X-Tenant-Code', currentTenantCode());
xhr.setRequestHeader('X-Request-ID', requestId());
if (onProgress) {
xhr.upload.onprogress = e => onProgress(e.lengthComputable ? e.loaded / e.total : 0);
}
xhr.onload = () => {
let payload = {};
try { payload = JSON.parse(xhr.responseText); } catch (e) { /* handled below */ }
if (xhr.status >= 200 && xhr.status < 300 && payload.code === 0) {
resolve(normalizeResponse(payload));
} else {
reject(new ImApiError(payload.message || payload.reason || '上传失败', payload.reason, xhr.status, payload.details));
}
};
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
xhr.send(form);
});
}
// Native large-file upload (Ch.6 group avatars & group files). uploadPublicFile() above can't be reused --
// its `purpose` is server-whitelisted to exactly 'enterprise_license'/'tenant_entry_feedback'. The vendored
// SDK's own tinode.getLargeFileHelper().upload() talks to the same underlying native endpoint but gets two
// headers wrong for this backend (verified live against the real server, not just read from source):
// it sends 'X-Tinode-APIKey' (server only accepts 'X-MeetingIM-APIKey', same rename Phase 0 already applied
// everywhere else in this file) and 'X-Tinode-Auth' (server's getHttpAuth() doesn't recognize that header
// name at all, only 'X-MeetingIM-Auth' or plain 'Authorization'). The auth *value* must be the native Tinode
// session token (tinode.getAuthToken().token, scheme 'Token') -- the REST layer's own sessionStorage Bearer
// token is a separate namespace and fails this endpoint's base64 secret decode with a 400. Response is
// Tinode's native {ctrl:{code,text,params:{url,expires}}} envelope, not the {code:0,data} REST envelope --
// there's no separate file_id field; the id is the last path segment of `url`, kept verbatim (including its
// extension suffix) since getFileUrl() already round-trips that exact format unmodified.
// This upload talks to Tinode's native {ctrl} protocol (see the comment above), not the REST layer,
// so REASON_MESSAGES above never applies to it -- confirmed against server/datamodel.go's Err*
// constructors for the literal ctrl.text values a failure here can actually carry.
const UPLOAD_CTRL_TEXT_CN = {
'too large': '文件大小超出限制',
'malformed': '请求内容有误,请稍后重试',
'permission denied': '没有权限执行此操作',
'gone': '内容不存在或已被删除',
'not found': '内容不存在或已被删除'
};
export function uploadLargeFile(tinode, file, onProgress) {
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
if (!authToken) {
return Promise.reject(new ImApiError('尚未登录,无法上传文件', 'AUTH_REQUIRED', 401));
}
const form = new FormData();
form.append('file', file);
form.set('id', tinode.getNextUniqueId());
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', fileBase() + '/u/');
xhr.setRequestHeader('X-MeetingIM-APIKey', API_KEY);
xhr.setRequestHeader('Authorization', 'Token ' + authToken.token);
if (onProgress) {
xhr.upload.onprogress = e => onProgress(e.lengthComputable ? e.loaded / e.total : 0);
}
xhr.onload = () => {
let payload = {};
try { payload = JSON.parse(xhr.responseText); } catch (e) { /* handled below */ }
const ctrl = payload.ctrl || {};
if (xhr.status >= 200 && xhr.status < 300 && ctrl.params && ctrl.params.url) {
const url = ctrl.params.url;
resolve({fileId: url.slice(url.lastIndexOf('/') + 1), url, expires: ctrl.params.expires});
} else {
const known = ctrl.text && UPLOAD_CTRL_TEXT_CN[ctrl.text.trim().toLowerCase()];
reject(new ImApiError(known || ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
}
};
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
xhr.send(form);
});
}
// Fixed-headers drop-in replacement for tinode.getLargeFileHelper(), for sending chat attachments
// (messages-view.jsx). The vendored SDK's LargeFileHelper hardcodes the same two wrong headers
// uploadLargeFile() above works around ('X-Tinode-APIKey' instead of 'X-MeetingIM-APIKey', and
// 'X-Tinode-Auth' instead of 'Authorization') -- confirmed live: every image/video/file/voice message
// too large to send inband was silently failing to upload. Can't just call uploadLargeFile() at each
// call site instead, because messages-view.jsx also depends on LargeFileHelper's cancel-in-flight-send
// support (handleCancelUpload) and its urlPromise contract (Drafty.attachFile/insertImage/insertVideo/
// insertAudio expect the resolved value to be a bare URL string, or for video a [url, prevUrl] pair --
// not the {fileId,url,expires} object uploadLargeFile() returns). This mirrors just that subset of the
// real class's interface (upload() returning a bare-URL promise, onProgress, and cancel() aborting any
// still-in-flight XHRs via the same `this.xhr` array pattern the real LargeFileHelper.cancel() uses).
export function createChatUploader(tinode) {
const xhrs = [];
return {
onProgress: null,
upload(file) {
const self = this;
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
if (!authToken) {
return Promise.reject(new ImApiError('尚未登录,无法上传文件', 'AUTH_REQUIRED', 401));
}
return new Promise((resolve, reject) => {
const form = new FormData();
form.append('file', file);
form.set('id', tinode.getNextUniqueId());
const xhr = new XMLHttpRequest();
xhrs.push(xhr);
xhr.open('POST', fileBase() + '/u/');
xhr.setRequestHeader('X-MeetingIM-APIKey', API_KEY);
xhr.setRequestHeader('Authorization', 'Token ' + authToken.token);
xhr.upload.onprogress = e => {
if (e.lengthComputable && self.onProgress) {
self.onProgress(e.loaded / e.total);
}
};
xhr.onload = () => {
let payload = {};
try { payload = JSON.parse(xhr.responseText); } catch (e) { /* handled below */ }
const ctrl = payload.ctrl || {};
if (xhr.status >= 200 && xhr.status < 300 && ctrl.params && ctrl.params.url) {
resolve(ctrl.params.url);
} else {
// Same native-{ctrl}-protocol translation as uploadLargeFile above -- REASON_MESSAGES
// never applies here, and a too-large photo/video/file picked from the attachment panel
// is a realistic, easy way for a real user to hit this.
const known = ctrl.text && UPLOAD_CTRL_TEXT_CN[ctrl.text.trim().toLowerCase()];
reject(new ImApiError(known || ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
}
};
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
xhr.send(form);
});
},
cancel() {
xhrs.forEach(req => { if (req.readyState < 4) { req.abort(); } });
}
};
}
// Fixed-headers drop-in replacement for tinode.getLargeFileHelper(), for downloading a chat/favorite
// out-of-band attachment (content-library-view.jsx's DownloadPage). Same wrong-header bug as
// createChatUploader() above -- the vendored SDK's LargeFileHelper.download() also hardcodes
// 'X-Tinode-APIKey'/'X-Tinode-Auth', so every real (non-blob:/data:) download was silently failing.
// Mirrors the real class's download()/cancel() interface exactly (including the onProgress/onError
// callback-style signature DownloadPage already calls it with) so the call site needs no other change.
export function createFileDownloader(tinode) {
const xhrs = [];
return {
download(relativeUrl, filename, mimetype, onProgress, onError) {
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
if (!authToken) {
const err = new ImApiError('尚未登录,无法下载文件', 'AUTH_REQUIRED', 401);
if (onError) { onError(err); }
return Promise.reject(err);
}
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhrs.push(xhr);
xhr.open('GET', relativeUrl, true);
xhr.setRequestHeader('X-MeetingIM-APIKey', API_KEY);
xhr.setRequestHeader('Authorization', 'Token ' + authToken.token);
xhr.responseType = 'blob';
xhr.onprogress = e => { if (onProgress) { onProgress(e.loaded); } };
xhr.onload = () => {
if (xhr.status === 200) {
const link = document.createElement('a');
link.href = window.URL.createObjectURL(new Blob([xhr.response], {type: mimetype}));
link.style.display = 'none';
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(link.href);
resolve();
} else {
const reader = new FileReader();
reader.onload = function() {
let err;
try {
const pkt = JSON.parse(this.result);
const text = pkt.ctrl && pkt.ctrl.text;
const known = text && UPLOAD_CTRL_TEXT_CN[text.trim().toLowerCase()];
err = new ImApiError(known || text || '下载失败', 'DOWNLOAD_FAILED', xhr.status);
} catch (e) {
err = new ImApiError('下载失败', 'DOWNLOAD_FAILED', xhr.status);
}
if (onError) { onError(err); }
reject(err);
};
reader.readAsText(xhr.response);
}
};
xhr.onerror = () => {
const err = new ImApiError('网络错误,下载失败', 'NETWORK_ERROR', 0);
if (onError) { onError(err); }
reject(err);
};
xhr.send();
});
},
cancel() {
xhrs.forEach(req => { if (req.readyState < 4) { req.abort(); } });
}
};
}
// -- 1.1 Tenant resolution & entry (pre-login, no Authorization header). --
export function resolveTenant(tenantCode) {
return imRequest('/tenants/resolve', {
method: 'POST', auth: false, tenantCodeOverride: tenantCode,
body: {tenant_code: tenantCode, platform: 'web', app_version: PACKAGE_VERSION || '0.25.3'}
});
}
// 企业注册申请 / 入口反馈 / 意见反馈三个接口后端都定义了 client_context 且会入库
// (imTenantApplicationRequest / imTenantEntryFeedbackRequest / imFeedbackRequest,
// server/im_http.go:255/263/367,落库点 :790/:840/:1478),需求文档和对接文档也都要求带上
// 设备与版本信息。前端一直没发。在这里统一注入,省得三处各写一遍、也不会再漏。
function clientContext() {
return {
platform: 'web',
app_version: PACKAGE_VERSION || '',
user_agent: (typeof navigator !== 'undefined' && navigator.userAgent) || '',
language: (typeof navigator !== 'undefined' && navigator.language) || '',
// 网络类型不是所有浏览器都有(Safari/Firefox 没有 navigator.connection),拿不到就不发这一项
network: (typeof navigator !== 'undefined' && navigator.connection && navigator.connection.effectiveType) || undefined
};
}
export function submitTenantApplication(body) {
return imRequest('/tenant-applications', {method: 'POST', auth: false, headers: idem(),
body: {client_context: clientContext(), ...body}});
}
export function submitTenantEntryFeedback(body) {
return imRequest('/tenant-entry-feedback', {method: 'POST', auth: false, headers: idem(),
body: {client_context: clientContext(), ...body}});
}
// -- 1.4/1.5/1.6 Phone verification codes (register / reset_password / security scenes). --
export function requestVerificationCode(scene, phone) {
return imRequest('/auth/verification-codes', {method: 'POST', auth: false, headers: idem(), body: {scene, phone}});
}
export function verifyRegisterCode(challengeId, code) {
return imRequest('/auth/verification-codes/' + encodeURIComponent(challengeId) + '/verify',
{method: 'POST', auth: false, body: {code}});
}
export function verifyPasswordResetCode(challengeId, code) {
return imRequest('/auth/password-reset/challenges/' + encodeURIComponent(challengeId) + '/verify',
{method: 'POST', auth: false, body: {code}});
}
export function confirmPasswordReset(resetToken, newPassword) {
return imRequest('/auth/password-reset/confirm',
{method: 'POST', auth: false, headers: idem(), body: {reset_token: resetToken, new_password: newPassword}});
}
// -- Bootstrap / home. --
export function getBootstrap() {
return imRequest('/bootstrap');
}
export function getMe() {
return imRequest('/me');
}
// -- Realname (1.8/1.9). --
export function getRealnameStatus() {
return imRequest('/security/realname');
}
export function submitRealname(body) {
return imPost('/security/realname/submissions', body);
}
// -- Security challenge (1.7) & independent password (1.11). --
export function createSecurityChallenge(scene) {
return imPost('/security/challenges', {scene, channel: 'sms'});
}
export function verifySecurityChallenge(challengeId, code) {
return imPost('/security/challenges/' + encodeURIComponent(challengeId) + '/verify', {code});
}
export function setIndependentPassword(password, purpose, actionToken) {
return imPost('/settings/independent-password', {password, purpose, action_token: actionToken});
}
// `action` defaults server-side to 'close_youth_mode' if omitted (server/im_http.go
// imIndependentPasswordVerifyRequest) -- pass 'set_independent_password' to mint a token usable
// with enableYouthMode instead, letting an already-set password unlock re-enabling without
// forcing another SMS round-trip through the identity-check flow.
export function verifyIndependentPassword(password, purpose, action) {
return imPost('/settings/independent-password/verify', {password, purpose, action});
}
// -- Friends / contacts (Ch.5). --
export const getFriends = (query = '', cursor = '', initial = '') => imGet('/friends' + qs({query, cursor, initial}));
export const getFriendRequests = (direction = 'inbound', status = 'all', cursor = '') =>
imGet('/friend-requests' + qs({direction, status, cursor}));
export const createFriendRequest = (targetUserId, message = '', source = 'search', sourceTopic = '') =>
imPost('/friend-requests', {target_user_id: targetUserId, message, source, source_topic: sourceTopic});
export const acceptFriendRequest = (requestId_, version) => imPost('/friend-requests/' + encodeURIComponent(requestId_) + '/accept', version ? {version} : {});
export const rejectFriendRequest = (requestId_, version) => imPost('/friend-requests/' + encodeURIComponent(requestId_) + '/reject', version ? {version} : {});
export const deleteFriend = userId => imDelete('/friends/' + encodeURIComponent(userId));
export const updateFriendRemark = (userId, remark) => imPatch('/friends/' + encodeURIComponent(userId), {remark});
export const searchUsers = (query, type = 'account', cursor = '') => imGet('/users/search' + qs({q: query, type, cursor}));
export const getUserProfile = identifier => imGet('/users/' + encodeURIComponent(identifier) + '/profile');
export const createMyQRCode = (expiresInSeconds = 86400) => imPost('/me/qrcodes', {purpose: 'add_friend', expires_in_seconds: expiresInSeconds});
export const createUserQRCode = (identifier, expiresInSeconds = 86400) =>
imPost('/users/' + encodeURIComponent(identifier) + '/qrcodes', {purpose: 'add_friend', expires_in_seconds: expiresInSeconds});
export const resolveQRCode = (token, purpose = 'add_friend') => imPost('/qrcodes/resolve', {token, purpose});
export const getBlocks = (cursor = '') => imGet('/blocks' + qs({cursor}));
export const blockUser = userId => imPut('/blocks/' + encodeURIComponent(userId), {});
export const unblockUser = userId => imDelete('/blocks/' + encodeURIComponent(userId));
export const batchUnblockUsers = userIds => imPost('/blocks/batch-delete', {user_ids: userIds});
// -- Conversations (Ch.2/3). --
export const searchConversations = (query = '', cursor = '') => imGet('/search/conversations' + qs({q: query, cursor}));
export const getSystemNotifications = (cursor = '', limit = 20) => imGet('/system-notifications' + qs({cursor, limit}));
export const markSystemNotificationRead = id => imPost('/system-notifications/' + encodeURIComponent(id) + '/read', {read_at: new Date().toISOString()});
export const getConversationContext = topic => imGet('/conversations/' + encodeURIComponent(topic) + '/context');
export const createP2PConversation = peerUserId => imPost('/conversations/p2p', {peer_user_id: peerUserId});
export const getConversationSettings = topic => imGet('/conversations/' + encodeURIComponent(topic) + '/settings');
export const updateConversationSettings = (topic, body) => imPatch('/conversations/' + encodeURIComponent(topic) + '/settings', body);
// -- Messages (Ch.4/7). --
// version 只能默认传 1:服务端做的是乐观锁校验(tenant_adapter.go:1963 要求 msg.Version 与传入值
// 完全相等,否则 ErrDuplicate),但**没有任何消息读取接口下发过 version 字段**——messagePayload /
// messageSearchPayload / mediaMessagePayload / favoriteSnapshot 全都没有,冲突时的错误里也不回传
// 真实值。所以前端拿不到正确的版本号,被编辑过(version>1)的消息撤回必然失败。这是后端缺口,
// 已记入《IM-后端问题清单.md》,不在前端绕。
export const recallMessage = (topic, seq, version = 1, reason = 'mistake') =>
imPost('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/recall', {version, reason});
export const reviseMessage = (topic, seq, content, version = 1) =>
imPost('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/revisions', {content, client_mid: requestId(), version});
export const getMessageReceipts = (topic, seq, cursor = '') =>
imGet('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/receipts' + qs({cursor}));
// type='all' searches every message type (text/image/video/voice/file/link) -- the backend's own
// default when the param is omitted/empty is "no filter", so defaulting to 'text' here would have
// silently restricted every unqualified call to text-only messages.
export const searchTopicMessages = (topic, {query = '', type = 'all', cursor = ''} = {}) =>
imGet('/topics/' + encodeURIComponent(topic) + '/messages/search' + qs({q: query, type, cursor}));
export const getMessageContext = (topic, seq, before = 20, after = 20) =>
imGet('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/context' + qs({before, after}));
export const getTopicMedia = (topic, type = 'image', cursor = '') =>
imGet('/topics/' + encodeURIComponent(topic) + '/media' + qs({type, cursor}));
export const getForwardTargets = (query = '', cursor = '') => imGet('/forward-targets' + qs({query, cursor}));
export const forwardMessages = (mode, sources, targetTopics, comment = '') =>
imPost('/messages/forward', {mode, sources, target_topics: targetTopics, comment});
export const getForwardBundle = (bundleId, cursor = '') => imGet('/forward-bundles/' + encodeURIComponent(bundleId) + qs({cursor}));
export const favoriteMessages = messages => imPost('/favorites/batch', {messages: messages.map(m => ({...m, seq: String(m.seq)}))});
export const getFavorites = ({type = 'all', query = '', cursor = '', limit} = {}) => imGet('/favorites' + qs({type, q: query, cursor, limit}));
export const getFavorite = favoriteId => imGet('/favorites/' + encodeURIComponent(favoriteId));
export const deleteFavorite = favoriteId => imDelete('/favorites/' + encodeURIComponent(favoriteId));
export const batchDeleteFavorites = favoriteIds => imPost('/favorites/batch-delete', {favorite_ids: favoriteIds});
export const forwardFavorites = (favoriteIds, targetTopics, comment = '') =>
imPost('/favorites/forward', {favorite_ids: favoriteIds, target_topics: targetTopics, comment});
export const checkURL = (url, source = 'favorite') => imPost('/security/urls/check', {url, source});
export const confirmRiskChallenge = (riskChallengeId, decision = 'continue') =>
imPost('/security/risk-challenges/' + encodeURIComponent(riskChallengeId) + '/confirm', {decision});
export const getMentionableGroupMembers = (topic, query = '', cursor = '') =>
imGet('/groups/' + encodeURIComponent(topic) + '/members' + qs({mentionable: true, query, cursor}));
// 4.1 跨话题草稿:GET 对不存在的草稿返回 {content:'',entities:[],version:0} 而不是 404,PUT 用
// version 做乐观锁(对接文档描述的样子)。真实验证过用原生话题名(P2P 是对方 user_id,不是
// REST 层内部的 p2pXXX id)可以正常读写——这个接口内部会先查 Subs.Get 校验会话存在/有权限,
// 跟 favorites 那条从不做话题归一化、直接 NOT_FOUND 的坏路径不是同一个问题。
export const getTopicDraft = topic => imGet('/topics/' + encodeURIComponent(topic) + '/draft');
export const updateTopicDraft = (topic, content, entities, version) =>
imPut('/topics/' + encodeURIComponent(topic) + '/draft', {content, entities, version});
export async function fetchFileState(fileId, range = '') {
const headers = {
'X-MeetingIM-APIKey': API_KEY, 'X-IM-Schema': '1',
'X-Tenant-Code': currentTenantCode(), 'X-Request-ID': requestId()
};
const token = authToken();
// GET /v0/file/s/{id} is the native Tinode file-serving endpoint, not the /v1/im REST API --
// it's auth-gated by the vendored SDK's auth-handler registry, which is keyed by the literal
// scheme word in the header and only recognizes 'token' (used by uploadLargeFile above), never
// 'bearer'. Sending 'Bearer' here (as imRequest correctly does for actual /v1/im/* calls) gets a
// flat 400 "malformed" from every single call -- confirmed live against the real backend: same
// token value, only the scheme word differs. authToken() itself already holds the right value
// (tinode-web.jsx's handleLoginSuccessful stores tinode.getAuthToken().token verbatim into
// sessionStorage['im-session-token']), so no plumbing change is needed beyond the scheme word.
if (token) {
headers.Authorization = 'Token ' + token;
}
if (range) {
headers.Range = range;
}
const response = await fetch(fileBase() + '/s/' + encodeURIComponent(fileId), {headers});
return {
ok: response.ok, status: response.status,
contentType: response.headers.get('content-type') || '',
contentLength: response.headers.get('content-length') || '',
acceptRanges: response.headers.get('accept-ranges') || '',
etag: response.headers.get('etag') || ''
};
}
export function publicFileBase() {
return fileBase();
}
// Just the URL string -- fileBase() (the tenant's real, possibly cross-origin file domain, e.g.
// "https://api.118im.com/v0/file") + the server-relative path a Drafty ref/upload response carries.
// No fetch. Split out from getFileUrl() below for DownloadPage (content-library-view.jsx): that
// caller needs a correctly-qualified URL to *display and hand to the download button*, not to
// eagerly pull the whole file into a blob the moment the page opens -- getFileUrl()'s eager fetch,
// used unmodified there, silently downloaded every out-of-band attachment in full before the user
// ever pressed "download", defeating the ready/downloading/done UI entirely.
export function resolveFileUrl(fileId) {
return fileId ? fileBase() + '/s/' + encodeURIComponent(fileId) : null;
}
// File serving (GET /v0/file/s/{id}) is auth-gated -- the upload endpoints never return a directly
// servable URL (publicUploadPayload only has file_id/purpose/mime_type/size/expires_at), so a plain
// <img src> can't be pointed at it (browsers don't attach custom headers to img/video/audio requests).
// This fetches the bytes with the right headers once per file_id and hands back a blob: object URL,
// cached so avatars/attachments repeated across a list (friends, search results, ...) aren't refetched.
const fileUrlCache = new Map();
export function getFileUrl(fileId) {
if (!fileId) {
return Promise.resolve(null);
}
if (fileUrlCache.has(fileId)) {
return fileUrlCache.get(fileId);
}
const headers = {
'X-MeetingIM-APIKey': API_KEY, 'X-IM-Schema': '1',
'X-Tenant-Code': currentTenantCode(), 'X-Request-ID': requestId()
};
const token = authToken();
// Same native-endpoint scheme requirement as fetchFileState() above -- 'Token', not 'Bearer'.
if (token) {
headers.Authorization = 'Token ' + token;
}
const promise = fetch(resolveFileUrl(fileId), {headers}).then(res => {
if (!res.ok) {
throw new ImApiError('文件加载失败', 'FILE_FETCH_FAILED', res.status);
}
return res.blob();
}).then(blob => URL.createObjectURL(blob)).catch(err => {
fileUrlCache.delete(fileId);
throw err;
});
fileUrlCache.set(fileId, promise);
return promise;
}
// -- Groups (Ch.6). --
export const getGroups = ({category = 'joined', query = '', cursor = ''} = {}) => imGet('/groups' + qs({category, query, cursor}));
export const createGroup = ({name, description = '', memberUserIds = [], avatarFileId = ''}) =>
imPost('/groups', {name, description, member_user_ids: memberUserIds, avatar_file_id: avatarFileId});
export const getGroup = topic => imGet('/groups/' + encodeURIComponent(topic));
export const updateGroup = (topic, body) => imPatch('/groups/' + encodeURIComponent(topic), body);
export const getGroupSettings = topic => imGet('/groups/' + encodeURIComponent(topic) + '/settings');
export const updateGroupMemberSettings = (topic, {pinned, mute_notifications}) =>
imPatch('/groups/' + encodeURIComponent(topic) + '/member-settings', {pinned, mute_notifications});
export const updateMyGroupProfile = (topic, nickname, version) =>
imPut('/groups/' + encodeURIComponent(topic) + '/members/me/profile', {nickname, version});
// GET .../members/me/profile is the only endpoint that actually returns the saved nickname --
// confirmed live that GET .../members (getGroupMembers below) never includes group_nickname on any
// item, so anything reading the nickname from that list (as this file's own callers used to) will
// always see it as unset even right after a successful save.
export const getMyGroupProfile = topic => imGet('/groups/' + encodeURIComponent(topic) + '/members/me/profile');
export const leaveGroup = topic => imPost('/groups/' + encodeURIComponent(topic) + '/leave', {});
export const getGroupMembers = (topic, {query = '', cursor = '', role = '', eligibleRole = '', mentionable, muteEligible} = {}) =>
imGet('/groups/' + encodeURIComponent(topic) + '/members' +
qs({query, cursor, role, eligible_role: eligibleRole, mentionable: mentionable ? 'true' : '', mute_eligible: muteEligible ? 'true' : ''}));
export const createGroupQRCode = (topic, expiresInSeconds = 86400) =>
imPost('/groups/' + encodeURIComponent(topic) + '/qrcodes', {expires_in_seconds: expiresInSeconds});
export const inviteGroupMembers = (topic, userIds, message = '') =>
imPost('/groups/' + encodeURIComponent(topic) + '/invitations', {user_ids: userIds, message});
export const acceptGroupInvitation = invitationId => imPost('/group-invitations/' + encodeURIComponent(invitationId) + '/accept', {});
export const createGroupJoinRequest = (topic, {message = '', source = 'qr', qrToken = ''} = {}) =>
imPost('/groups/' + encodeURIComponent(topic) + '/join-requests', {message, source, qr_token: qrToken});
export const getGroupJoinRequests = (topic, status = 'pending', cursor = '') =>
imGet('/groups/' + encodeURIComponent(topic) + '/join-requests' + qs({status, cursor}));
export const acceptGroupJoinRequest = (topic, requestId_, version) =>
imPost('/groups/' + encodeURIComponent(topic) + '/join-requests/' + encodeURIComponent(requestId_) + '/accept', version ? {version} : {});
// Server struct (imGroupJoinRequestActionRequest) only has `Version` -- no reason field exists, and the
// business layer hardcodes an empty reject-reason even internally, so there's nothing to send here. Every
// JSON body goes through decodeIMJSONBody's DisallowUnknownFields(), so including an extra `reason` key
// (as an earlier draft of this function did) makes every single call 400 regardless of its value.
export const rejectGroupJoinRequest = (topic, requestId_, version) =>
imPost('/groups/' + encodeURIComponent(topic) + '/join-requests/' + encodeURIComponent(requestId_) + '/reject', version ? {version} : {});
export const getGroupSummary = topic => imGet('/groups/' + encodeURIComponent(topic) + '/summary');
export const getLatestGroupAnnouncement = topic => imGet('/groups/' + encodeURIComponent(topic) + '/announcements/latest');
export const updateCurrentGroupAnnouncement = (topic, content, version) =>
imPut('/groups/' + encodeURIComponent(topic) + '/announcements/current', {content, version});
export const getGroupAnnouncement = (topic, announcementId) =>
imGet('/groups/' + encodeURIComponent(topic) + '/announcements/' + encodeURIComponent(announcementId));
export const readGroupAnnouncement = (topic, announcementId) =>
imPost('/groups/' + encodeURIComponent(topic) + '/announcements/' + encodeURIComponent(announcementId) + '/read', {});
export const getGroupGovernance = topic => imGet('/groups/' + encodeURIComponent(topic) + '/governance');
export const updateGroupGovernance = (topic, body) => imPatch('/groups/' + encodeURIComponent(topic) + '/governance', body);
export const getGroupRoleLimits = topic => imGet('/groups/' + encodeURIComponent(topic) + '/role-limits');
export const addGroupAdmins = (topic, userIds, version) => imPost('/groups/' + encodeURIComponent(topic) + '/admins/batch', {user_ids: userIds, version});
// Must merge into idem()'s headers, not replace them (imDelete/imPost's `{headers: idem(), ...opts}`
// lets a same-key `opts.headers` clobber the whole object) -- the same Idempotency-Key-goes-missing
// bug this codebase already found and fixed once in disableYouthMode.
export const deleteGroupAdmin = (topic, userId, version) =>
imDelete('/groups/' + encodeURIComponent(topic) + '/admins/' + encodeURIComponent(userId), version ? {headers: {...idem(), 'If-Match': String(version)}} : {});
export const muteGroupMembers = (topic, members, reason = '') => imPost('/groups/' + encodeURIComponent(topic) + '/mutes', {members, reason});
export const getGroupMutes = (topic, cursor = '') => imGet('/groups/' + encodeURIComponent(topic) + '/mutes' + qs({cursor}));
export const unmuteGroupMember = (topic, userId) => imDelete('/groups/' + encodeURIComponent(topic) + '/mutes/' + encodeURIComponent(userId));
export const transferGroupOwner = (topic, targetUserId, version, actionToken = '') =>
imPost('/groups/' + encodeURIComponent(topic) + '/owner-transfer', {target_user_id: targetUserId, version},
actionToken ? {headers: {...idem(), 'X-Action-Token': actionToken}} : {});
// Real backend reads the search text from `q`, not `query` (confirmed against ListFiles's real
// query-param handling, same bug class as getOrganizationMembers) -- the search box in Group Files
// was silently a no-op.
export const getGroupFiles = (topic, {query = '', type = '', cursor = ''} = {}) =>
imGet('/groups/' + encodeURIComponent(topic) + '/files' + qs({q: query, type, cursor}));
// All fields besides file_id are `omitempty` server-side, so omitting them (as an earlier draft of this
// function did) never errors -- it just registers a file with a blank name and 0 size in the files list.
export const addGroupFile = (topic, {fileId, fileName = '', mimeType = '', fileType = '', fileSize = 0, sourceTopic = '', sourceSeq} = {}) =>
imPost('/groups/' + encodeURIComponent(topic) + '/files', {
file_id: fileId, file_name: fileName, mime_type: mimeType, file_type: fileType, file_size: fileSize,
source_topic: sourceTopic, source_seq: sourceSeq
});
export const deleteGroupFile = (topic, groupFileId) => imDelete('/groups/' + encodeURIComponent(topic) + '/files/' + encodeURIComponent(groupFileId));
export const getGroupMemberProfile = (topic, userId) => imGet('/groups/' + encodeURIComponent(topic) + '/members/' + encodeURIComponent(userId) + '/profile');
// -- Organization / workbench (Ch.9). --
export const getWorkbench = () => imGet('/workbench');
export const getOrganizationDepartments = (parentId = 'root', cursor = '') => imGet('/organization/departments' + qs({parent_id: parentId, cursor}));
export const getOrganizationDepartmentMembers = (departmentId, cursor = '') =>
imGet('/organization/departments/' + encodeURIComponent(departmentId) + '/members' + qs({cursor}));
// Real backend only reads `q` (server/im_http.go), never `query` -- confirmed against
// server/im_http_test.go's own request fixtures. This function used to send `query`, silently making
// every caller's search box a no-op (server ignores the unrecognized param and returns the unfiltered
// list every time).
export const getOrganizationMembers = ({query = '', departmentId = '', cursor = '', limit = ''} = {}) =>
imGet('/organization/members' + qs({q: query, department_id: departmentId, cursor, limit}));
export const searchOrganization = (query = '', types = 'department,user', cursor = '') =>
imGet('/organization/search' + qs({q: query, types, cursor}));
export const getMeetings = (cursor = '', limit = 20) => imGet('/meetings' + qs({limit, cursor}));
export const createMeeting = body => imPost('/meetings', body);
export const getMeeting = meetingId => imGet('/meetings/' + encodeURIComponent(meetingId));
export const updateMeeting = (meetingId, body) => imPatch('/meetings/' + encodeURIComponent(meetingId), body);
export const cancelMeeting = (meetingId, version, reason = '时间冲突') => imPost('/meetings/' + encodeURIComponent(meetingId) + '/cancel', {version, reason});
export const sendMeetingReminders = meetingId => imPost('/meetings/' + encodeURIComponent(meetingId) + '/reminders', {});
export const respondMeeting = (meetingId, response, version) => imPut('/meetings/' + encodeURIComponent(meetingId) + '/my-response', {response, version});
// Note: the server only reads scope/from/to/status/cursor -- 'date' and 'department_id' are
// silently ignored (confirmed against server/im_http.go), so a single-day filter must be
// expressed as from==to, and there is no way to scope this list to one department.
export const getDailyReports = ({scope = 'mine', from = '', to = '', status = '', cursor = ''} = {}) =>
imGet('/reports/daily' + qs({scope, from, to, status, cursor}));
export const createDailyReport = reportDate => imPost('/reports/daily', {report_date: reportDate});
export const getDailyReport = reportId => imGet('/reports/daily/' + encodeURIComponent(reportId));
export const updateDailyDraft = (reportId, content, version, attachments = []) =>
imPut('/reports/daily/' + encodeURIComponent(reportId) + '/draft', {content, attachments, version});
export const submitDailyReport = (reportId, version) => imPost('/reports/daily/' + encodeURIComponent(reportId) + '/submit', {version});
// TenantDailyReportStats does honor department_id (reportMemberScopeWhere adds `m.department_id=?`
// when it's non-empty) -- confirmed against current im_be HEAD, this wasn't always true and an earlier
// comment here claimed the opposite.
export const getDailyStatistics = (date, departmentId = '') => imGet('/reports/daily/statistics' + qs({date, department_id: departmentId}));
export const sendDailyReminders = (date, userIds = []) => imPost('/reports/daily/reminders', {date, user_ids: userIds});
export const getMonthlyReports = ({scope = 'mine', fromMonth = '', toMonth = '', status = '', cursor = ''} = {}) =>
imGet('/reports/monthly' + qs({scope, from_month: fromMonth, to_month: toMonth, status, cursor}));
export const createMonthlyReport = month => imPost('/reports/monthly', {month});
export const getMonthlyReport = reportId => imGet('/reports/monthly/' + encodeURIComponent(reportId));
export const updateMonthlyDraft = (reportId, workSummary, nextPlan, version, attachments = []) =>
imPut('/reports/monthly/' + encodeURIComponent(reportId) + '/draft', {work_summary: workSummary, next_plan: nextPlan, attachments, version});
export const submitMonthlyReport = (reportId, version) => imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/submit', {version});
// server/im_http.go's serveIMMonthlyReportReopen decodes only {version} (DisallowUnknownFields
// rejects anything else) -- an earlier draft of this function sent an extra `reason` field that
// would 400 MALFORMED_REQUEST on every real call. Also: the store layer's UPDATE has
// `WHERE ... AND author_id=?` keyed off the *caller's* uid, so this only ever lets a report's own
// author reopen their own already-submitted report for further editing -- there is no
// manager-reopens-someone-else's-report capability on the backend.
export const reopenMonthlyReport = (reportId, version) =>
imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/reopen', {version});
// Same department_id support as getDailyStatistics (TenantMonthlyReportStats shares reportMemberScopeWhere).
export const getMonthlyStatistics = (month, departmentId = '') => imGet('/reports/monthly/statistics' + qs({month, department_id: departmentId}));
export const getExecutiveMailboxConfig = () => imGet('/executive-mailbox/config');
export const submitExecutiveMailboxMessage = body => imPost('/executive-mailbox/submissions', body);
// -- Personal settings (Ch.10). --
export const getMeProfile = () => imGet('/me/profile');
export const updateMeProfile = body => imPatch('/me/profile', body);
export const getMePrivacy = () => imGet('/me/privacy');
export const updateMePrivacy = body => imPatch('/me/privacy', body);
export const getYouthMode = () => imGet('/settings/youth-mode');
export const enableYouthMode = (policyVersion, actionToken) => imPost('/settings/youth-mode/enable', {policy_version: policyVersion, action_token: actionToken});
// imPost spreads `opts` last, so passing `headers` directly would silently replace (not merge
// with) the Idempotency-Key header from idem() -- confirmed live: the server actually enforces
// it ("Idempotency-Key is required", 400), unlike the earlier research pass's assumption that a
// missing key here was harmless. Merge explicitly instead of letting the spread clobber it.
export const disableYouthMode = actionToken => imPost('/settings/youth-mode/disable', {}, {headers: {...idem(), 'X-Action-Token': actionToken}});
export const getLatestAppRelease = ({platform = 'android', currentVersion = '1.0.0', channel = 'stable'} = {}) =>
imGet('/app/releases/latest' + qs({platform, current_version: currentVersion, channel}));
export const updateDevicePushToken = (deviceId, body) => imPut('/devices/' + encodeURIComponent(deviceId) + '/push-token', body);
// -- Reports & feedback (Ch.11). --
export const getAbuseReasons = (targetType = 'user') => imGet('/reports/abuse/reasons' + qs({target_type: targetType}));
export const submitAbuseReport = body => imPost('/reports/abuse', body);
export const getFeedbackConfig = () => imGet('/feedback/config');
export const submitFeedback = body => imPost('/feedback', {client_context: clientContext(), ...body});