Finalize JamiSwig interface and implement account detail routes

Changes:
- Add missing methods to JamiSwig interface
- Create new stringMapToRecord and itToRecord functions
- Rename id to accountId everywhere for consistency
- Rename jamid to jamiSwig inside Jamid service for clarity
- Implement Jamid functionality:
    - getVolatileAccountDetails
    - getAccountDetails with AccountDetails interface
    - setAccountDetails
    - getDevices
    - getDefaultModerators
- Reorder and rename methods in Jamid for consistency with JamiSwig
- Implement all routes for accountRouter (GET/POST /account)
- Add various TODO comments for future work

Change-Id: Id14ddde3bc8b4484d82ad84c57384567d92bd70f
diff --git a/server/src/jamid/jami-swig.ts b/server/src/jamid/jami-swig.ts
index 63fc307..6ce5a71 100644
--- a/server/src/jamid/jami-swig.ts
+++ b/server/src/jamid/jami-swig.ts
@@ -15,7 +15,7 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { itMap, itRange, itToArr, itToMap } from '../utils.js';
+import { itMap, itRange, itToArr, itToMap, itToRecord } from './utils.js';
 
 enum Bool {
   False = 'false',
@@ -33,26 +33,66 @@
   set(k: T, v: U): void;
 }
 
+// TODO: Review these conversion functions
 const swigVecToIt = <T>(v: SwigVec<T>) => itMap(itRange(0, v.size()), (i) => v.get(i));
 const swigMapToIt = <T, U>(m: SwigMap<T, U>) => itMap(swigVecToIt(m.keys()), (k): [T, U] => [k, m.get(k)]);
 
-// type IntVect = SwigVec<number>;
-// type UintVect = SwigVec<number>;
-// type FloatVect = SwigVec<number>;
+// export type IntVect = SwigVec<number>;
+// export type UintVect = SwigVec<number>;
+// export type FloatVect = SwigVec<number>;
 export type StringVect = SwigVec<string>;
-// type IntegerMap = SwigMap<string, number>;
+// export type IntegerMap = SwigMap<string, number>;
 export type StringMap = SwigMap<string, string>;
-// type VectMap = SwigVec<StringMap>;
-// type Blob = SwigVec<number>;
+export type VectMap = SwigVec<StringMap>;
+// export type Blob = SwigVec<number>;
 
-export const stringVectToArr = (sv: StringVect) => itToArr(swigVecToIt(sv));
+// TODO: Consider always converting to Record rather than Map as conversion to interfaces is easier
+export const stringVectToArray = (sv: StringVect) => itToArr(swigVecToIt(sv));
+export const stringMapToRecord = (sm: StringMap) => itToRecord(swigMapToIt(sm));
 export const stringMapToMap = (sm: StringMap) => itToMap(swigMapToIt(sm));
-// const vectMapToJs = (vm: VectMap) => itToArr(itMap(swigVecToIt(vm), stringMapToMap));
+// export const vectMapToArrayMap = (vm: VectMap) => itToArr(itMap(swigVecToIt(vm), stringMapToMap));
 
 export interface JamiSwig {
   init(args: Record<string, unknown>): void;
   fini(): void;
 
+  getAccountDetails(accountId: string): StringMap;
+  getVolatileAccountDetails(accountId: string): StringMap;
+  setAccountDetails(accountId: string, details: StringMap): void;
+  setAccountActive(accountId: string, active: Bool): void;
+
+  addAccount(details: StringMap): string;
+  removeAccount(accountId: string): void;
+
+  getAccountList(): StringVect;
+
+  lookupName(accountId: string, nameserver: string, username: string): boolean;
+  lookupAddress(accountId: string, nameserver: string, address: string): boolean;
+  registerName(accountId: string, password: string, username: string): boolean;
+
+  getKnownRingDevices(accountId: string): StringMap;
+
+  getAudioOutputDeviceList(): StringVect;
+
+  getVolume(device: string): number;
+  setVolume(device: string, value: number): void;
+
+  addContact(accountId: string, contactId: string): void;
+  removeContact(accountId: string, contactId: string, ban: boolean): void;
+  getContacts(accountId: string): VectMap;
+  getContactDetails(accountId: string, contactId: string): StringMap;
+
+  getDefaultModerators(accountId: string): StringVect;
+  setDefaultModerators(accountId: string, uri: string, state: boolean): void;
+
+  getConversations(accountId: string): StringVect;
+  conversationInfos(accountId: string, conversationId: string): StringMap;
+
+  getConversationMembers(accountId: string, conversationId: string): VectMap;
+
+  sendMessage(accountId: string, conversationId: string, message: string, replyTo: string): void;
+  loadConversationMessages(accountId: string, conversationId: string, fromMessage: string, n: number): number;
+
   // IntVect(): IntVect;
   // UintVect(): UintVect;
   // FloatVect(): FloatVect;
@@ -61,17 +101,4 @@
   // StringMap(): StringMap;
   // VectMap(): VectMap;
   // IntegerMap(): IntegerMap;
-
-  addAccount(details: StringMap): string;
-  removeAccount(id: string): void;
-
-  getAccountList(): StringVect;
-
-  registerName(id: string, password: string, username: string): boolean;
-  lookupName(id: string, nameserver: string, username: string): boolean;
-  lookupAddress(id: string, nameserver: string, address: string): boolean;
-
-  getAccountDetails(id: string): StringMap;
-  setAccountDetails(id: string, details: StringMap): void;
-  setAccountActive(id: string, active: Bool): void;
 }
diff --git a/server/src/jamid/jamid.ts b/server/src/jamid/jamid.ts
index b2d81c4..51394eb 100644
--- a/server/src/jamid/jamid.ts
+++ b/server/src/jamid/jamid.ts
@@ -15,11 +15,11 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
+import { AccountDetails, VolatileDetails } from 'jami-web-common';
 import log from 'loglevel';
 import { filter, firstValueFrom, Subject } from 'rxjs';
 import { Service } from 'typedi';
 
-import { itMap, require } from '../utils.js';
 import { JamiSignal } from './jami-signal.js';
 import {
   NameRegistrationEnded,
@@ -27,17 +27,18 @@
   RegistrationStateChanged,
   VolatileDetailsChanged,
 } from './jami-signal-interfaces.js';
-import { JamiSwig, StringMap, stringMapToMap, stringVectToArr } from './jami-swig.js';
+import { JamiSwig, StringMap, stringMapToRecord, stringVectToArray } from './jami-swig.js';
+import { require } from './utils.js';
 
 @Service()
 export class Jamid {
-  private readonly jamid: JamiSwig;
-  private readonly mapUsernameToAccountId: Map<string, string>;
+  private readonly jamiSwig: JamiSwig;
+  private readonly usernamesToAccountIds: Map<string, string>;
   private readonly events;
 
   constructor() {
     // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
-    this.jamid = require('../jamid.node') as JamiSwig;
+    this.jamiSwig = require('../../jamid.node') as JamiSwig;
 
     const handlers: Record<string, unknown> = {};
     const handler = (sig: string) => {
@@ -73,7 +74,7 @@
       // Keep map of usernames to account IDs as Jamid cannot do this by itself (AFAIK)
       const username = details.get('Account.registeredName');
       if (username) {
-        this.mapUsernameToAccountId.set(username, accountId);
+        this.usernamesToAccountIds.set(username, accountId);
       }
     });
     this.events.onRegistrationStateChanged.subscribe((ctx) =>
@@ -82,62 +83,65 @@
     this.events.onNameRegistrationEnded.subscribe((ctx) => log.debug('[1] Received onNameRegistrationEnded with', ctx));
     this.events.onRegisteredNameFound.subscribe((ctx) => log.debug('[1] Received onRegisteredNameFound with', ctx));
 
-    this.mapUsernameToAccountId = new Map<string, string>();
+    this.usernamesToAccountIds = new Map<string, string>();
 
     // 1. You cannot change event handlers after init
     // 2. You cannot specify multiple handlers for the same event
     // 3. You cannot specify a default handler
-    // So we rely on the Subject() instead of Observable()
+    // So we rely on Subject() instead of Observable()
     // Also, handlers receive multiple argument instead of tuple or object!
-    this.jamid.init(handlers);
+    this.jamiSwig.init(handlers);
   }
 
   stop() {
-    this.jamid.fini();
+    this.jamiSwig.fini();
   }
 
-  getAccountList() {
-    return stringVectToArr(this.jamid.getAccountList());
+  getVolatileAccountDetails(accountId: string): VolatileDetails {
+    return stringMapToRecord(this.jamiSwig.getVolatileAccountDetails(accountId)) as unknown as VolatileDetails;
   }
 
-  async createAccount(details: Map<string, string | number | boolean>) {
-    // TODO: add proper typing directly into JamiSwig
-    const stringMapDetails: StringMap = new (this.jamid as any).StringMap();
+  getAccountDetails(accountId: string): AccountDetails {
+    return stringMapToRecord(this.jamiSwig.getAccountDetails(accountId)) as unknown as AccountDetails;
+  }
 
-    stringMapDetails.set('Account.type', 'RING');
-    itMap(details.entries(), ([k, v]) => stringMapDetails.set('Account.' + k, v.toString()));
+  setAccountDetails(accountId: string, accountDetails: AccountDetails) {
+    const accountDetailsStringMap: StringMap = new (this.jamiSwig as any).StringMap();
+    for (const [key, value] of Object.entries(accountDetails)) {
+      accountDetailsStringMap.set(key, value);
+    }
+    this.jamiSwig.setAccountDetails(accountId, accountDetailsStringMap);
+  }
 
-    const id = this.jamid.addAccount(stringMapDetails);
+  async addAccount(details: Map<string, string | number | boolean>) {
+    // TODO: Add proper typing directly into JamiSwig
+    const detailsStringMap: StringMap = new (this.jamiSwig as any).StringMap();
+
+    detailsStringMap.set('Account.type', 'RING');
+    for (const [key, value] of details.entries()) {
+      detailsStringMap.set('Account.' + key, value.toString());
+    }
+
+    const accountId = this.jamiSwig.addAccount(detailsStringMap);
     return firstValueFrom(
       this.events.onRegistrationStateChanged.pipe(
-        filter(({ accountId }) => accountId === id),
+        filter(({ accountId: addedAccountId }) => addedAccountId === accountId),
         // TODO: is it the only state?
         filter(({ state }) => state === 'REGISTERED')
       )
     );
   }
 
-  destroyAccount(id: string) {
-    this.jamid.removeAccount(id);
+  removeAccount(accountId: string) {
+    this.jamiSwig.removeAccount(accountId);
   }
 
-  async registerUsername(id: string, username: string, password: string) {
-    const hasRingNs = this.jamid.registerName(id, password, username);
-    if (!hasRingNs) {
-      log.error('Jami does not have NS');
-      throw new Error('Jami does not have NS');
-    }
-    return firstValueFrom(this.events.onNameRegistrationEnded.pipe(filter(({ accountId }) => accountId === id)));
-  }
-
-  // TODO: Ideally, we would fetch the username directly from Jami instead of
-  // keeping an internal map.
-  usernameToAccountId(username: string) {
-    return this.mapUsernameToAccountId.get(username);
+  getAccountList(): string[] {
+    return stringVectToArray(this.jamiSwig.getAccountList());
   }
 
   async lookupUsername(username: string) {
-    const hasRingNs = this.jamid.lookupName('', '', username);
+    const hasRingNs = this.jamiSwig.lookupName('', '', username);
     if (!hasRingNs) {
       log.error('Jami does not have NS');
       throw new Error('Jami does not have NS');
@@ -145,7 +149,30 @@
     return firstValueFrom(this.events.onRegisteredNameFound.pipe(filter((r) => r.username === username)));
   }
 
-  getAccountDetails(id: string) {
-    return stringMapToMap(this.jamid.getAccountDetails(id));
+  async registerUsername(accountId: string, username: string, password: string) {
+    const hasRingNs = this.jamiSwig.registerName(accountId, password, username);
+    if (!hasRingNs) {
+      log.error('Jami does not have NS');
+      throw new Error('Jami does not have NS');
+    }
+    return firstValueFrom(
+      this.events.onNameRegistrationEnded.pipe(
+        filter(({ accountId: registeredAccountId }) => registeredAccountId === accountId)
+      )
+    );
+  }
+
+  getDevices(accountId: string): Record<string, string> {
+    return stringMapToRecord(this.jamiSwig.getKnownRingDevices(accountId));
+  }
+
+  getDefaultModerators(accountId: string): string[] {
+    return stringVectToArray(this.jamiSwig.getDefaultModerators(accountId));
+  }
+
+  // TODO: Ideally, we would fetch the username directly from Jami instead of
+  // keeping an internal map.
+  getAccountIdFromUsername(username: string): string | undefined {
+    return this.usernamesToAccountIds.get(username);
   }
 }
diff --git a/server/src/utils.ts b/server/src/jamid/utils.ts
similarity index 87%
rename from server/src/utils.ts
rename to server/src/jamid/utils.ts
index 4f0b027..0952fec 100644
--- a/server/src/utils.ts
+++ b/server/src/jamid/utils.ts
@@ -17,6 +17,8 @@
  */
 import { createRequire } from 'node:module';
 
+// TODO: Move these functions to jami-swig.ts
+
 export function* itRange(lo: number, hi: number) {
   for (let i = lo; i < hi; ++i) {
     yield i;
@@ -49,4 +51,12 @@
   return m;
 };
 
+export const itToRecord = <T>(it: Iterable<[string, T]>) => {
+  const r: Record<string, T> = {};
+  for (const [k, v] of it) {
+    r[k] = v;
+  }
+  return r;
+};
+
 export const require = createRequire(import.meta.url);
diff --git a/server/src/routers/account-router.ts b/server/src/routers/account-router.ts
index e1d4adc..0798fa7 100644
--- a/server/src/routers/account-router.ts
+++ b/server/src/routers/account-router.ts
@@ -16,7 +16,7 @@
  * <https://www.gnu.org/licenses/>.
  */
 import { Router } from 'express';
-import log from 'loglevel';
+import { AccountDetails } from 'jami-web-common';
 import { Container } from 'typedi';
 
 import { Jamid } from '../jamid/jamid.js';
@@ -26,12 +26,27 @@
 
 export const accountRouter = Router();
 
-accountRouter.get('/', authenticateToken, (req, res) => {
-  log.debug('TODO: Implement jamid.getAccount()');
-  res.send(`TODO: ${req.method} ${req.originalUrl} for account ID ${res.locals.accountId}`);
+accountRouter.use(authenticateToken);
+
+// TODO: If tokens can be generated on one daemon and used on another (transferrable between daemons),
+// then add middleware to check that the currently logged-in accountId is stored in this daemon instance
+
+accountRouter.get('/', (_req, res) => {
+  const accountId = res.locals.accountId;
+
+  res.json({
+    id: accountId,
+    details: jamid.getAccountDetails(accountId),
+    volatileDetails: jamid.getVolatileAccountDetails(accountId),
+    defaultModerators: jamid.getDefaultModerators(accountId),
+    devices: jamid.getDevices(accountId),
+  });
 });
 
-accountRouter.post('/', authenticateToken, (req, res) => {
-  log.debug('TODO: Implement jamid.getAccount().updateDetails()');
-  res.send(`TODO: ${req.method} ${req.originalUrl} for account ID ${res.locals.accountId}`);
+accountRouter.post('/', (req, res) => {
+  const accountId = res.locals.accountId;
+  const currentAccountDetails = jamid.getAccountDetails(accountId);
+  const newAccountDetails: AccountDetails = { ...currentAccountDetails, ...req.body };
+  jamid.setAccountDetails(res.locals.accountId, newAccountDetails);
+  res.end();
 });
diff --git a/server/src/routers/auth-router.ts b/server/src/routers/auth-router.ts
index 2aab780..08796a7 100644
--- a/server/src/routers/auth-router.ts
+++ b/server/src/routers/auth-router.ts
@@ -56,12 +56,12 @@
     // TODO: find a way to store the password directly in Jami
     // Maybe by using the "password" field? But as I tested, it's not
     // returned when getting user infos.
-    const { accountId } = await jamid.createAccount(new Map());
+    const { accountId } = await jamid.addAccount(new Map());
 
     // TODO: understand why the password arg in this call must be empty
     const { state } = await jamid.registerUsername(accountId, username, '');
     if (state !== 0) {
-      jamid.destroyAccount(accountId);
+      jamid.removeAccount(accountId);
       if (state === 2) {
         res.status(StatusCode.BAD_REQUEST).send('Invalid username or password');
       } else if (state === 3) {
@@ -91,7 +91,7 @@
     // The account may either be:
     // 1. not found
     // 2. found but not on this instance (but I'm not sure about this)
-    const accountId = jamid.usernameToAccountId(username);
+    const accountId = jamid.getAccountIdFromUsername(username);
     if (accountId === undefined) {
       res.status(StatusCode.NOT_FOUND).send('Username not found');
       return;
diff --git a/server/src/ws.ts b/server/src/ws.ts
index 74a7a06..eadbab0 100644
--- a/server/src/ws.ts
+++ b/server/src/ws.ts
@@ -34,6 +34,7 @@
     const wss = new WebSocketServer({ noServer: true });
     wss.on('connection', (ws: WebSocket, _req: IncomingMessage, accountId: string) => {
       log.info('New connection', accountId);
+      // TODO: Add the account ID here to a map of accountId -> WebSocket connections
 
       ws.on('message', (_data) => {
         ws.send(JSON.stringify({ accountId }));