unit Api.ServiceImpl;

interface

uses
  XData.Server.Module, XData.Service.Common, Api.Database, Data.DB,
  System.SysUtils, System.Generics.Collections, XData.Sys.Exceptions, System.StrUtils,
  System.Hash, System.Classes, Common.Logging, System.JSON, Api.Service, VCL.Forms,
  Auth.Service, Uni, UniProvider, PostgreSQLUniProvider, Common.Ini,
  Sparkle.HttpServer.Context, System.NetEncoding;


type
  [ServiceImplementation]
  TApiService = class(TInterfacedObject, IApiService)
  strict private
  ApiDB: TApiDatabaseModule;
  private
    procedure AfterConstruction; override;
    procedure BeforeDestruction; override;
    procedure RequireAdmin;
    function OpenLemsConnection: TUniConnection;
  public
    function GetBadgeCounts: TJSONObject;
    function GetComplaintList: TJSONObject;
    function GetUnitList: TJSONObject;
    function GetComplaintMap: TJSONObject;
    function GetUnitMap: TJSONObject;
    function GetComplaintDetails(const ComplaintId: string): TJSONObject;
    function GetComplaintArchiveDetails(const ComplaintId: string): TJSONObject;
    function GetComplaintHistory(const ComplaintId: string): TJSONObject;
    function GetComplaintContacts(const ComplaintId: string): TJSONObject;
    function GetComplaintWarnings(const ComplaintId: string): TJSONObject;
    function GetUnitDetails(const UnitId: string): TJSONObject;
    function GetUnitLogs(const UnitId: string): TJSONObject;
    function GetComplaintMemos(const CfsId: string): TJSONObject;
    function GetDeviceList: TDeviceList;
    function RevokeDevice(const CredentialId: string): TJSONObject;
  end;

implementation

uses
  uLibrary;

procedure TApiService.AfterConstruction;
begin
  inherited;
  ApiDB := TApiDatabaseModule.Create(nil);
  Logger.Log(3, 'ApiDatabaseModule created');
end;

procedure TApiService.BeforeDestruction;
begin
  ApiDB.Free;
  inherited;
  Logger.Log(3, 'ApiDatabaseModule destroyed');
end;


function TApiService.GetBadgeCounts: TJSONObject;
begin
  Logger.Log(3, '---TApiService.GetBadgeCounts initiated');

  Result := TJSONObject.Create;
  TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);

  try
    with ApiDB.uqBadgeCounts do
    begin
      Open;
      try
        Result.AddPair('BadgeComplaints', TJSONNumber.Create(FieldByName('COMPLAINTS').AsInteger));
        Result.AddPair('BadgeUnits', TJSONNumber.Create(FieldByName('UNITS').AsInteger));
      finally
        Close;
      end;
    end;
  except
    on E: Exception do
    begin
      Logger.Log(2, '---TApiService.GetBadgeCounts error: ' + E.ClassName + ': ' + E.Message);
      raise EXDataHttpException.Create(500, 'Failed to load badge counts');
    end;
  end;

  Logger.Log(3, '---TApiService.GetBadgeCounts End');
end;

function TApiService.GetComplaintMap: TJSONObject;
var
  data: TJSONArray;
  emitted: Integer;
  item: TJSONObject;
  UnitsByComplaintMap: TObjectDictionary<string, TJSONArray>;
  complaintId: string;
  complaintNumber: string;
  unitArray: TJSONArray;
  unitStatus: string;
  latestUpdate: TDateTime;
  unitObj: TJSONObject;
begin
  Logger.Log(3, '---TApiService.GetComplaintMap initiated');

  Result := TJSONObject.Create;
  TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);

  data := TJSONArray.Create;
  Result.AddPair('data', data);

  UnitsByComplaintMap := TObjectDictionary<string, TJSONArray>.Create([doOwnsValues]);
  try
    try
      ApiDB.uqMapComplaintUnitsList.Close;
      ApiDB.uqMapComplaintUnitsList.Open;
      try
        while not ApiDB.uqMapComplaintUnitsList.Eof do
        begin
          complaintId := ApiDB.uqMapComplaintUnitsListCOMPLAINTID.AsString;

          if not UnitsByComplaintMap.TryGetValue(complaintId, unitArray) then
          begin
            unitArray := TJSONArray.Create;
            UnitsByComplaintMap.Add(complaintId, unitArray);
          end;

          unitStatus := 'Dispatched';

          if not ApiDB.uqMapComplaintUnitsListDATECLEARED.IsNull then
            unitStatus := 'Cleared'
          else if not ApiDB.uqMapComplaintUnitsListDATEARRIVED.IsNull then
            unitStatus := 'On Scene'
          else if not ApiDB.uqMapComplaintUnitsListDATERESPONDED.IsNull then
            unitStatus := 'Enroute';

          latestUpdate := 0;

          if not ApiDB.uqMapComplaintUnitsListDATEDISPATCHED.IsNull then
            latestUpdate := ApiDB.uqMapComplaintUnitsListDATEDISPATCHED.AsDateTime;

          if (not ApiDB.uqMapComplaintUnitsListDATERESPONDED.IsNull) and
             (ApiDB.uqMapComplaintUnitsListDATERESPONDED.AsDateTime > latestUpdate) then
            latestUpdate := ApiDB.uqMapComplaintUnitsListDATERESPONDED.AsDateTime;

          if (not ApiDB.uqMapComplaintUnitsListDATEARRIVED.IsNull) and
             (ApiDB.uqMapComplaintUnitsListDATEARRIVED.AsDateTime > latestUpdate) then
            latestUpdate := ApiDB.uqMapComplaintUnitsListDATEARRIVED.AsDateTime;

          if (not ApiDB.uqMapComplaintUnitsListDATECLEARED.IsNull) and
             (ApiDB.uqMapComplaintUnitsListDATECLEARED.AsDateTime > latestUpdate) then
            latestUpdate := ApiDB.uqMapComplaintUnitsListDATECLEARED.AsDateTime;

          unitObj := TJSONObject.Create;
          unitArray.AddElement(unitObj);

          unitObj.AddPair('Unit', ApiDB.uqMapComplaintUnitsListUNITNAME.AsString);
          unitObj.AddPair('Status', unitStatus);

          if latestUpdate <> 0 then
            unitObj.AddPair('Updated', FormatDateTime('yyyy-mm-dd hh:nn:ss', latestUpdate))
          else
            unitObj.AddPair('Updated', '');

          ApiDB.uqMapComplaintUnitsList.Next;
        end;
      finally
        ApiDB.uqMapComplaintUnitsList.Close;
      end;

      emitted := 0;

      ApiDB.uqMapComplaints.Close;
      ApiDB.uqMapComplaints.Open;
      try
        while not ApiDB.uqMapComplaints.Eof do
        begin
          item := TJSONObject.Create;
          data.AddElement(item);

          item.AddPair('ComplaintId', ApiDB.uqMapComplaintsCOMPLAINTID.AsString);

          complaintNumber := Trim(ApiDB.uqMapComplaintsCOMPLAINT.AsString);
          if Length(complaintNumber) > 2 then
            complaintNumber := Copy(complaintNumber, 1, 2) + '-' + Copy(complaintNumber, 3, MaxInt);

          item.AddPair('Complaint', complaintNumber);
          item.AddPair('DispatchDistrict', ApiDB.uqMapComplaintsDISPATCHDISTRICT.AsString);
          item.AddPair('Agency', ApiDB.uqMapComplaintsAGENCY.AsString);
          item.AddPair('AgencyName', ApiDB.uqMapComplaintsAGENCY_NAME.AsString);
          item.AddPair('DispatchCodeDesc', ApiDB.uqMapComplaintsDISPATCH_CODE_DESC.AsString);
          item.AddPair('DispatchCodeCategory', ApiDB.uqMapComplaintsDISPATCHCODECATEGORY.AsString);
          item.AddPair('Priority', ApiDB.uqMapComplaintsPRIORITY.AsString);
          item.AddPair('PriorityBadge', ApiDB.uqMapComplaintspriorityBadge.AsString);
          item.AddPair('ComplaintStatusKey', ApiDB.uqMapComplaintscomplaintStatusKey.AsString);
          item.AddPair('pngName', ApiDB.uqMapComplaintspngName.AsString);
          item.AddPair('Address', ApiDB.uqMapComplaintsADDRESS.AsString);
          item.AddPair('Business', ApiDB.uqMapComplaintsBUSINESS.AsString);

          complaintId := ApiDB.uqMapComplaintsCOMPLAINTID.AsString;

          if UnitsByComplaintMap.TryGetValue(complaintId, unitArray) then
            item.AddPair('Units', TJSONArray(unitArray.Clone))
          else
            item.AddPair('Units', TJSONArray.Create);

          item.AddPair('Lat', TJSONNumber.Create(ApiDB.uqMapComplaintsLAT.AsFloat));
          item.AddPair('Lng', TJSONNumber.Create(ApiDB.uqMapComplaintsLNG.AsFloat));

          Inc(emitted);
          ApiDB.uqMapComplaints.Next;
        end;
      finally
        ApiDB.uqMapComplaints.Close;
      end;

      Result.AddPair('count', TJSONNumber.Create(data.Count));
      Result.AddPair('returned', TJSONNumber.Create(emitted));

      Logger.Log(3, '---TApiService.GetComplaintMap End (returned=' + IntToStr(emitted) + ')');
    except
      on E: EXDataHttpException do
      begin
        Logger.Log(2, '---TApiService.GetComplaintMap http error: ' + E.ClassName + ': ' + E.Message);
        raise;
      end;
      on E: Exception do
      begin
        Logger.Log(2, '---TApiService.GetComplaintMap error: ' + E.ClassName + ': ' + E.Message);
        raise EXDataHttpException.Create(500, 'Failed to load complaint map');
      end;
    end;
  finally
    UnitsByComplaintMap.Free;
  end;
end;


function TApiService.GetUnitMap: TJSONObject;
var
  data: TJSONArray;
  item: TJSONObject;
  unitStatus: string;
  updateTimeText: string;
begin
  Logger.Log(4, '---TApiService.GetUnitMap initiated');

  Result := TJSONObject.Create;
  TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);

  data := TJSONArray.Create;
  Result.AddPair('data', data);

  try
    with ApiDB.uqMapUnits do
    begin
      Close;
      Open;
      try
        First;
        while not Eof do
        begin
          if (not ApiDB.uqMapUnitsGPS_LATITUDE.IsNull) and (not ApiDB.uqMapUnitsGPS_LONGITUDE.IsNull) then
          begin
            item := TJSONObject.Create;
            data.AddElement(item);

            item.AddPair('UnitId', ApiDB.uqMapUnitsUNITID.AsString);
            item.AddPair('UnitName', ApiDB.uqMapUnitsUNITNAME.AsString);
            item.AddPair('UnitBadge', ApiDB.uqMapUnitsUNITNAME.AsString);
            item.AddPair('Agency', ApiDB.uqMapUnitsAGENCY.AsString);
            item.AddPair('AgencyName', ApiDB.uqMapUnitsAGENCY_NAME.AsString);
            item.AddPair('AgencyType', ApiDB.uqMapUnitsAGENCYTYPE.AsString);

            item.AddPair('Lat', TJSONNumber.Create(ApiDB.uqMapUnitsGPS_LATITUDE.AsFloat));
            item.AddPair('Lng', TJSONNumber.Create(ApiDB.uqMapUnitsGPS_LONGITUDE.AsFloat));

            item.AddPair('CallType', ApiDB.uqMapUnitsCALL_TYPE.AsString);
            item.AddPair('Priority', ApiDB.uqMapUnitsPRIORITY.AsString);

            unitStatus := ApiDB.uqMapUnitsUNIT_STATUS_DESC.AsString;
            if Trim(unitStatus) = '' then
              unitStatus := 'Available';
            item.AddPair('Status', unitStatus);

            updateTimeText := '';
            if not ApiDB.uqMapUnitsUPDATE_TIME.IsNull then
              updateTimeText := FormatDateTime('yyyy-mm-dd hh:nn:ss', ApiDB.uqMapUnitsUPDATE_TIME.AsDateTime);
            item.AddPair('UpdateTime', updateTimeText);

            item.AddPair('Officer1Lname', ApiDB.uqMapUnitsOFFICER1_LNAME.AsString);
            item.AddPair('Officer1Fname', ApiDB.uqMapUnitsOFFICER1_FNAME.AsString);
            item.AddPair('Officer1Empnum', ApiDB.uqMapUnitsOFFICER1_EMPNUM.AsString);

            item.AddPair('Officer2Lname', ApiDB.uqMapUnitsOFFICER2_LNAME.AsString);
            item.AddPair('Officer2Fname', ApiDB.uqMapUnitsOFFICER2_FNAME.AsString);
            item.AddPair('Officer2Empnum', ApiDB.uqMapUnitsOFFICER2_EMPNUM.AsString);

            item.AddPair('CanShowDetails', TJSONBool.Create(not ApiDB.uqMapUnitsDIS_UNITID.IsNull));

          end;

          Next;
        end;
      finally
        Close;
      end;
    end;

    Result.AddPair('count', TJSONNumber.Create(data.Count));
    Result.AddPair('returned', TJSONNumber.Create(data.Count));
  except
    on E: EXDataHttpException do
    begin
      Logger.Log(2, '---TApiService.GetUnitMap http error: ' + E.ClassName + ': ' + E.Message);
      raise;
    end;
    on E: Exception do
    begin
      Logger.Log(2, '---TApiService.GetUnitMap error: ' + E.ClassName + ': ' + E.Message);
      raise EXDataHttpException.Create(500, 'Failed to load unit map');
    end;
  end;
  Logger.Log(4, '---TApiService.GetUnitMap End');
end;




function TApiService.GetComplaintList: TJSONObject;
var
  data: TJSONArray;
  lastDistrict: string;
begin
  Logger.Log(3, '---TApiService.GetComplaintList initiated');

  Result := TJSONObject.Create;
  TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);

  data := TJSONArray.Create;
  try
    lastDistrict := '';
    with ApiDB.uqComplaintList do
    begin
      Open;
      (FieldByName('DATEREPORTED') as TDateTimeField).DisplayFormat := 'yyyy-mm-dd hh:nn:ss';
      First;
      while not Eof do
      begin
        var status: string;
        var statusColor: string;
        var statusTextColor: string;

        if not FieldByName('DATEARRIVED').IsNull then
        begin
          status := 'On Scene';
          statusColor := '#22C55E';
          statusTextColor := '#000000';
        end
        else if not FieldByName('DATERESPONDED').IsNull then
        begin
          status := 'Enroute';
          statusColor := '#FFFF00';
          statusTextColor := '#000000';
        end
        else if not FieldByName('DATEDISPATCHED').IsNull then
        begin
          status := 'Dispatched';
          statusColor := '#FFFF00';
          statusTextColor := '#000000';
        end
        else
        begin
          status := 'Pending';
          statusColor := '#FF8080';
          statusTextColor := '#000000';
        end;

        var item := TJSONObject.Create;

        var curAgency := Trim(ApiDB.uqComplaintListAGENCY.AsString);
        if curAgency = '' then
          curAgency := 'Unknown Agency';

        if not SameText(curAgency, lastDistrict) then
          item.AddPair('DistrictHeader', curAgency);
        lastDistrict := curAgency;

        item.AddPair('AgencyLine', 'Agency: ' + curAgency);

        var colorVal := ApiDB.uqComplaintListPRIORITY_COLOR.AsInteger;
        item.AddPair('PriorityColor', '#' + IntToHex(colorVal and $FFFFFF, 6));
        item.AddPair('PriorityTextColor', '#000000');
        item.AddPair('ComplaintId', ApiDB.uqComplaintListCOMPLAINTID.AsString);
        item.AddPair('Complaint', ApiDB.uqComplaintListcomplaintNumber.AsString);
        item.AddPair('Agency', ApiDB.uqComplaintListAGENCY.AsString);
        item.AddPair('Priority', ApiDB.uqComplaintListPRIORITY.AsString);
        item.AddPair('DispatchCodeDesc', ApiDB.uqComplaintListDISPATCH_CODE_DESC.AsString);
        item.AddPair('Address', ApiDB.uqComplaintListADDRESS.AsString);
        item.AddPair('Business', ApiDB.uqComplaintListBUSINESS.AsString);
        item.AddPair('CFSId', ApiDB.uqComplaintListCFSID.AsString);
        item.AddPair('Status', status);
        item.AddPair('StatusColor', statusColor);
        item.AddPair('StatusTextColor', statusTextColor);
        item.AddPair('DispatchDistrict', ApiDB.uqComplaintListDISPATCHDISTRICT.AsString);
        item.AddPair('DateReported', ApiDB.uqComplaintListDATEREPORTED.AsString);

        data.AddElement(item);
        Next;
      end;
    end;

    Result.AddPair('count', TJSONNumber.Create(data.Count));
    Result.AddPair('returned', TJSONNumber.Create(data.Count));
    Result.AddPair('data', data);
  except
    data.Free;
    Logger.Log(2, '---TApiService.GetComplaintList End (error)');
    raise EXDataHttpException.Create(500, 'Failed to load complaints list');
  end;

  Logger.Log(3, '---TApiService.GetComplaintList End');
end;


function TApiService.GetUnitList: TJSONObject;
var
  data: TJSONArray;
  lastAgency: string;
begin
  Logger.Log(3, '---TApiService.GetUnitList initiated');

  Result := TJSONObject.Create;
  TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);

  data := TJSONArray.Create;
  try
    lastAgency := '';

    with ApiDB.uqUnitList do
    begin
      Open;
      First;

      while not Eof do
      begin
        var item := TJSONObject.Create;

        // Section: Group header shown once when the agency changes.
        var curAgency := Trim(ApiDB.uqUnitListAGENCY_NAME.AsString);
        if curAgency = '' then
          curAgency := Trim(ApiDB.uqUnitListAGENCY.AsString);
        if curAgency = '' then
          curAgency := 'Unknown Agency';

        if not SameText(curAgency, lastAgency) then
        begin
          item.AddPair('AgencyHeader', curAgency);
          lastAgency := curAgency;
        end
        else
          item.AddPair('AgencyHeader', '');

        // Section: Core unit identity.
        item.AddPair('UnitId', ApiDB.uqUnitListUNITID.AsString);
        item.AddPair('UnitName', ApiDB.uqUnitListUNITNAME.AsString);
        item.AddPair('Agency', ApiDB.uqUnitListAGENCY.AsString);
        item.AddPair('AgencyName', ApiDB.uqUnitListAGENCY_NAME.AsString);
        item.AddPair('AgencyType', ApiDB.uqUnitListAGENCYTYPE.AsString);
        item.AddPair('CarNumberDesc', ApiDB.uqUnitListCARNUMBER_DESC.AsString);
        item.AddPair('District', ApiDB.uqUnitListDISTRICT_DESC.AsString);
        item.AddPair('Sector', ApiDB.uqUnitListSECTOR_DESC.AsString);
        item.AddPair('CallType', ApiDB.uqUnitListCALL_TYPE.AsString);

        // Section: Map button availability.
        var canShowMap := False;

        if (not ApiDB.uqUnitListGPS_LATITUDE.IsNull) and
           (not ApiDB.uqUnitListGPS_LONGITUDE.IsNull) then
        begin
          var mapLat := ApiDB.uqUnitListGPS_LATITUDE.AsFloat;
          var mapLng := ApiDB.uqUnitListGPS_LONGITUDE.AsFloat;

          canShowMap :=
            ((mapLat <> 0) or (mapLng <> 0)) and
            (Abs(mapLat) <= 90) and
            (Abs(mapLng) <= 180);
        end;

        item.AddPair('CanShowMap', IfThen(canShowMap, 'true', 'false'));
        item.AddPair(
          'MapButtonClass',
          IfThen(canShowMap, 'btn-primary', 'btn-secondary disabled')
        );
        item.AddPair(
          'MapButtonDisabled',
          IfThen(
            canShowMap,
            '',
            'disabled="disabled" aria-disabled="true"'
          )
        );
        item.AddPair(
          'MapButtonTitle',
          IfThen(
            canShowMap,
            'Show on map',
            'No map location available'
          )
        );

        // Section: Current complaint assignment.
        var complaintNumber :=
          Trim(ApiDB.uqUnitListCOMPLAINT.AsString);

        item.AddPair(
          'Location',
          ApiDB.uqUnitListLOCATION.AsString
        );
        item.AddPair('Complaint', complaintNumber);

        if complaintNumber <> '' then
          item.AddPair(
            'ComplaintHeader',
            ' - ' + complaintNumber
          )
        else
          item.AddPair('ComplaintHeader', '');

        // Section: Default to Available when there is no active CFS row.
        var statusDesc :=
          ApiDB.uqUnitListUNIT_STATUS_DESC.AsString;

        if statusDesc = '' then
          statusDesc := 'Available';

        item.AddPair('Status', statusDesc);

        // Section: Primary officer.
        var o1 :=
          Trim(ApiDB.uqUnitListOFFICER1_LAST_NAME.AsString);
        var f1 :=
          Trim(ApiDB.uqUnitListOFFICER1_FIRST_NAME.AsString);
        var m1 :=
          Trim(ApiDB.uqUnitListOFFICER1_MI.AsString);

        if o1 <> '' then
        begin
          if f1 <> '' then
            o1 := o1 + ', ' + f1;
          if m1 <> '' then
            o1 := o1 + ' ' + m1;

          item.AddPair('Officer1', o1);
        end;

        // Section: Secondary officer.
        var o2 :=
          Trim(ApiDB.uqUnitListOFFICER2_LAST_NAME.AsString);
        var f2 :=
          Trim(ApiDB.uqUnitListOFFICER2_FIRST_NAME.AsString);
        var m2 :=
          Trim(ApiDB.uqUnitListOFFICER2_MI.AsString);

        if o2 <> '' then
        begin
          if f2 <> '' then
            o2 := o2 + ', ' + f2;
          if m2 <> '' then
            o2 := o2 + ' ' + m2;

          item.AddPair('Officer2', o2);
        end;

        data.AddElement(item);
        Next;
      end;
    end;

    Result.AddPair(
      'count',
      TJSONNumber.Create(data.Count)
    );
    Result.AddPair(
      'returned',
      TJSONNumber.Create(data.Count)
    );
    Result.AddPair('data', data);
  except
    data.Free;
    Logger.Log(
      3,
      '---TApiService.GetUnitList End (error)'
    );
    raise EXDataHttpException.Create(
      500,
      'Failed to load units list'
    );
  end;

  Logger.Log(3, '---TApiService.GetUnitList End');
end;


function TApiService.GetComplaintDetails(const ComplaintId: string): TJSONObject;
var
  obj: TJSONObject;
begin
  Logger.Log(3,'---TApiService.GetComplaintDetails initiated: '+ComplaintId);
  Result := TJSONObject.Create;
  TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);
  try
    with ApiDB.uqComplaintDetails do
    begin
      ParamByName('COMPLAINTID').AsString := ComplaintId;
      Open;
      try
        if Eof then raise EXDataHttpException.Create(404,'Complaint not found');

        obj := TJSONObject.Create;
        obj.AddPair('ComplaintId', ApiDB.uqComplaintDetailsCOMPLAINTID.AsString);
        obj.AddPair('CFSId', ApiDB.uqComplaintDetailsCFSID.AsString);
        obj.AddPair('Complaint', ApiDB.uqComplaintDetailsCOMPLAINT.AsString);
        obj.AddPair('Priority', ApiDB.uqComplaintDetailsPRIORITY.AsString);
        obj.AddPair('DispatchCode', ApiDB.uqComplaintDetailsDISPATCHCODE.AsString);
        obj.AddPair('DispatchCodeDesc', ApiDB.uqComplaintDetailsDISPATCH_CODE_DESC.AsString);
        obj.AddPair('DispatchDistrict', ApiDB.uqComplaintDetailsDISPATCHDISTRICT.AsString);
        obj.AddPair('Address', ApiDB.uqComplaintDetailsADDRESS.AsString);
        obj.AddPair('Business',ApiDB.uqComplaintDetailsBUSINESS.AsString);
        obj.AddPair('History', ApiDB.uqComplaintDetailsHISTORY.AsString);
        obj.AddPair('Contacts', ApiDB.uqComplaintDetailsCONTACTS.AsString);
        obj.AddPair('Warnings', ApiDB.uqComplaintDetailsWARNINGS.AsString);

        if ApiDB.uqComplaintDetailsDATEREPORTED.IsNull then
          obj.AddPair('DateReported', '')
        else
          obj.AddPair('DateReported', FormatDateTime('yyyy-mm-dd hh:nn:ss', ApiDB.uqComplaintDetailsDATEREPORTED.AsDateTime));

        if ApiDB.uqComplaintDetailsDATERECEIVED.IsNull then
          obj.AddPair('DateReceived', '')
        else
          obj.AddPair('DateReceived', FormatDateTime('yyyy-mm-dd hh:nn:ss', ApiDB.uqComplaintDetailsDATERECEIVED.AsDateTime));

        if ApiDB.uqComplaintDetailsDATEDISPATCHED.IsNull then
          obj.AddPair('DateDispatched', '')
        else
          obj.AddPair('DateDispatched', FormatDateTime('yyyy-mm-dd hh:nn:ss', ApiDB.uqComplaintDetailsDATEDISPATCHED.AsDateTime));

        if ApiDB.uqComplaintDetailsDATERESPONDED.IsNull then
          obj.AddPair('DateResponded', '')
        else
          obj.AddPair('DateResponded', FormatDateTime('yyyy-mm-dd hh:nn:ss', ApiDB.uqComplaintDetailsDATERESPONDED.AsDateTime));

        if ApiDB.uqComplaintDetailsDATEARRIVED.IsNull then
          obj.AddPair('DateArrived', '')
        else
          obj.AddPair('DateArrived', FormatDateTime('yyyy-mm-dd hh:nn:ss', ApiDB.uqComplaintDetailsDATEARRIVED.AsDateTime));

        if ApiDB.uqComplaintDetailsDATECLEARED.IsNull then
          obj.AddPair('DateCleared', '')
        else
          obj.AddPair('DateCleared', FormatDateTime('yyyy-mm-dd hh:nn:ss', ApiDB.uqComplaintDetailsDATECLEARED.AsDateTime));

        Result.AddPair('data', obj);

      finally
        Close;
      end;
    end;
  except
    on E: EXDataHttpException do
    begin
      Logger.Log(3,'---TApiService.GetComplaintDetails not found');
      raise;
    end;
    on E: Exception do
    begin
      Logger.Log(3,'---TApiService.GetComplaintDetails error: '+E.Message);
      raise EXDataHttpException.Create(500,'Failed to load complaint details');
    end;
  end;
  Logger.Log(3,'---TApiService.GetComplaintDetails End');
end;


function TApiService.GetComplaintArchiveDetails(const ComplaintId: string): TJSONObject;
var
  obj: TJSONObject;
begin
  Logger.Log(3,'---TApiService.GetComplaintArchiveDetails initiated: '+ComplaintId);
  Result := TJSONObject.Create;
  TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);
  try
    with ApiDB.uqComplaintDetails do
    begin
      ParamByName('COMPLAINTID').AsString := ComplaintId;
      Open;
      try
        if Eof then raise EXDataHttpException.Create(404,'Complaint not found');

        if not Locate('STATUS', 1, []) then
          raise EXDataHttpException.Create(404,'Complaint archive not found');

        obj := TJSONObject.Create;

        obj.AddPair('ComplaintId', ApiDB.uqComplaintDetailsCOMPLAINTID.AsString);
        obj.AddPair('CFSId', ApiDB.uqComplaintDetailsCFSID.AsString);
        obj.AddPair('Complaint', ApiDB.uqComplaintDetailsCOMPLAINT.AsString);
        obj.AddPair('Priority', ApiDB.uqComplaintDetailsPRIORITY.AsString);
        obj.AddPair('DispatchCode', ApiDB.uqComplaintDetailsDISPATCHCODE.AsString);
        obj.AddPair('DispatchCodeDesc', ApiDB.uqComplaintDetailsDISPATCH_CODE_DESC.AsString);
        obj.AddPair('DispatchDistrict', ApiDB.uqComplaintDetailsDISPATCHDISTRICT.AsString);
        obj.AddPair('Address', ApiDB.uqComplaintDetailsADDRESS.AsString);
        obj.AddPair('Business', ApiDB.uqComplaintDetailsBUSINESS.AsString);
        obj.AddPair('History', ApiDB.uqComplaintDetailsHISTORY.AsString);
        obj.AddPair('Contacts', ApiDB.uqComplaintDetailsCONTACTS.AsString);
        obj.AddPair('Warnings', ApiDB.uqComplaintDetailsWARNINGS.AsString);

        if ApiDB.uqComplaintDetailsDATEREPORTED.IsNull then
          obj.AddPair('DateReported', '')
        else
          obj.AddPair('DateReported', FormatDateTime('yyyy-mm-dd hh:nn:ss', ApiDB.uqComplaintDetailsDATEREPORTED.AsDateTime));

        if ApiDB.uqComplaintDetailsDATERECEIVED.IsNull then
          obj.AddPair('DateReceived', '')
        else
          obj.AddPair('DateReceived', FormatDateTime('yyyy-mm-dd hh:nn:ss', ApiDB.uqComplaintDetailsDATERECEIVED.AsDateTime));

        if ApiDB.uqComplaintDetailsDATEDISPATCHED.IsNull then
          obj.AddPair('DateDispatched', '')
        else
          obj.AddPair('DateDispatched', FormatDateTime('yyyy-mm-dd hh:nn:ss', ApiDB.uqComplaintDetailsDATEDISPATCHED.AsDateTime));

        if ApiDB.uqComplaintDetailsDATERESPONDED.IsNull then
          obj.AddPair('DateResponded', '')
        else
          obj.AddPair('DateResponded', FormatDateTime('yyyy-mm-dd hh:nn:ss', ApiDB.uqComplaintDetailsDATERESPONDED.AsDateTime));

        if ApiDB.uqComplaintDetailsDATEARRIVED.IsNull then
          obj.AddPair('DateArrived', '')
        else
          obj.AddPair('DateArrived', FormatDateTime('yyyy-mm-dd hh:nn:ss', ApiDB.uqComplaintDetailsDATEARRIVED.AsDateTime));

        if ApiDB.uqComplaintDetailsDATECLEARED.IsNull then
          obj.AddPair('DateCleared', '')
        else
          obj.AddPair('DateCleared', FormatDateTime('yyyy-mm-dd hh:nn:ss', ApiDB.uqComplaintDetailsDATECLEARED.AsDateTime));

        Result.AddPair('data', obj);
      finally
        Close;
      end;
    end;
  except
    on E: EXDataHttpException do
    begin
      Logger.Log(3,'---TApiService.GetComplaintArchiveDetails not found');
      raise;
    end;
    on E: Exception do
    begin
      Logger.Log(3,'---TApiService.GetComplaintArchiveDetails error: '+E.Message);
      raise EXDataHttpException.Create(500,'Failed to load complaint archive details');
    end;
  end;
  Logger.Log(3,'---TApiService.GetComplaintArchiveDetails End');
end;


function TApiService.GetComplaintMemos(const CfsId: string): TJSONObject;
var
  data: TJSONArray;
  item: TJSONObject;
  ts: string;
begin
  Logger.Log(3, '---TApiService.GetComplaintMemos initiated: ' + CfsId);

  Result := TJSONObject.Create;
  TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);

  data := TJSONArray.Create;
  Result.AddPair('data', data);

  try
    with ApiDB.uqCFSMemos do
    begin
      ParamByName('CFSID').AsString := CfsId;
      Open;
      try
        First;
        while not Eof do
        begin
          item := TJSONObject.Create;
          data.AddElement(item);

          item.AddPair('MemoId', ApiDB.uqCFSMemosMEMO_ID.AsString);
          item.AddPair('CFSId', ApiDB.uqCFSMemosCFSID.AsString);
          item.AddPair('MemoType', ApiDB.uqCFSMemosMEMO_TYPE.AsString);

          if ApiDB.uqCFSMemosTIMESTAMP.IsNull then
            ts := ''
          else
            ts := FormatDateTime('yyyy-mm-dd' + '  ' + 'hh:nn:ss', ApiDB.uqCFSMemosTIMESTAMP.AsDateTime);
          item.AddPair('Timestamp', ts);

          item.AddPair('BadgeNumber', ApiDB.uqCFSMemosBADGE_NUMBER.AsString);
          item.AddPair('Remarks', ApiDB.uqCFSMemosREMARKS.AsString);

          Next;
        end;
      finally
        Close;
      end;
    end;

    Result.AddPair('count', TJSONNumber.Create(data.Count));
    Result.AddPair('returned', TJSONNumber.Create(data.Count));
  except
    on E: EXDataHttpException do
    begin
      Logger.Log(2, '---TApiService.GetComplaintMemos http error: ' + E.ClassName + ': ' + E.Message);
      raise;
    end;
    on E: Exception do
    begin
      Logger.Log(2, '---TApiService.GetComplaintMemos error: ' + E.ClassName + ': ' + E.Message);
      raise EXDataHttpException.Create(500, 'Failed to load complaint memos');
    end;
  end;
  Logger.Log(3, '---TApiService.GetComplaintMemos End');
end;


function TApiService.GetComplaintHistory(const ComplaintId: string): TJSONObject;
var
  dataArr: TJSONArray;
  rowObj: TJSONObject;
  returnedCount: Integer;
begin
  Logger.Log(4, '---TApiService.GetComplaintHistory initiated: ' + ComplaintId);

  Result := TJSONObject.Create;
  TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);

  dataArr := TJSONArray.Create;
  Result.AddPair('data', dataArr);

  returnedCount := 0;

  try
    with ApiDB.uqComplaintHistory do
    begin
      ParamByName('COMPLAINTID').AsString := ComplaintId;
      Open;
      try
        while not Eof do
        begin
          if returnedCount >= 50 then
            Break;

          rowObj := TJSONObject.Create;
          dataArr.AddElement(rowObj);

          rowObj.AddPair('ComplaintId', ApiDB.uqComplaintHistoryCOMPLAINTID.AsString);
          rowObj.AddPair('Complaint', ApiDB.uqComplaintHistoryCOMPLAINT.AsString);
          rowObj.AddPair('Apartment', ApiDB.uqComplaintHistoryAPARTMENT.AsString);

          if ApiDB.uqComplaintHistoryDATEREPORTED.IsNull then
            rowObj.AddPair('DateReported', '')
          else
            rowObj.AddPair('DateReported', FormatDateTime('yyyy-mm-dd', ApiDB.uqComplaintHistoryDATEREPORTED.AsDateTime));

          rowObj.AddPair('DPriority', ApiDB.uqComplaintHistoryDPRIORITY.AsString);
          rowObj.AddPair('DCallType', ApiDB.uqComplaintHistoryDCALLTYPE.AsString);

          Inc(returnedCount);
          Next;
        end;
      finally
        Close;
      end;
    end;

    Result.AddPair('count', TJSONNumber.Create(returnedCount));
    Result.AddPair('returned', TJSONNumber.Create(returnedCount));

    Logger.Log(3, '---TApiService.GetComplaintHistory End (returned=' + IntToStr(returnedCount) + ')');
  except
    on E: EXDataHttpException do
    begin
      Logger.Log(2, '---TApiService.GetComplaintHistory http error: ' + E.Message);
      raise;
    end;
    on E: Exception do
    begin
      Logger.Log(2, '---TApiService.GetComplaintHistory error: ' + E.ClassName + ': ' + E.Message);
      raise EXDataHttpException.Create(500, 'Failed to load complaint history');
    end;
  end;
end;

function TApiService.GetComplaintContacts(const ComplaintId: string): TJSONObject;
var
  dataArr: TJSONArray;
  rowObj: TJSONObject;
  returnedCount: Integer;
begin
  Logger.Log(4, '---TApiService.GetComplaintContacts initiated: ' + ComplaintId);

  Result := TJSONObject.Create;
  TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);

  dataArr := TJSONArray.Create;
  Result.AddPair('data', dataArr);

  returnedCount := 0;

  try
    with ApiDB.uqComplaintContacts do
    begin
      ParamByName('COMPLAINTID').AsString := ComplaintId;
      Open;
      try
        while not Eof do
        begin
          rowObj := TJSONObject.Create;
          dataArr.AddElement(rowObj);

          rowObj.AddPair('Name', FieldByName('NAME').AsString);
          rowObj.AddPair('Phone', FieldByName('PHONE').AsString);
          rowObj.AddPair('DContactType', FieldByName('DCONTACTTYPE').AsString);
          rowObj.AddPair('Remarks', FieldByName('REMARKS').AsString);

          Inc(returnedCount);
          Next;
        end;
      finally
        Close;
      end;
    end;

    Result.AddPair('count', TJSONNumber.Create(returnedCount));
    Result.AddPair('returned', TJSONNumber.Create(returnedCount));

    Logger.Log(3, '---TApiService.GetComplaintContacts End (returned=' + IntToStr(returnedCount) + ')');
  except
    on E: EXDataHttpException do
    begin
      Logger.Log(2, '---TApiService.GetComplaintContacts http error: ' + E.Message);
      raise;
    end;
    on E: Exception do
    begin
      Logger.Log(2, '---TApiService.GetComplaintContacts error: ' + E.ClassName + ': ' + E.Message);
      raise EXDataHttpException.Create(500, 'Failed to load complaint contacts');
    end;
  end;
end;

function TApiService.GetComplaintWarnings(const ComplaintId: string): TJSONObject;
var
  dataArr: TJSONArray;
  rowObj: TJSONObject;
  returnedCount: Integer;
  addressText: string;
  apartmentText: string;
  cityText: string;
begin
  Logger.Log(3, '---TApiService.GetComplaintWarnings initiated: ' + ComplaintId);

  Result := TJSONObject.Create;
  TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);

  dataArr := TJSONArray.Create;
  Result.AddPair('data', dataArr);

  returnedCount := 0;

  try
    with ApiDB.uqComplaintWarnings do
    begin
      ParamByName('COMPLAINTID').AsString := ComplaintId;
      Open;
      try
        while not Eof do
        begin
          rowObj := TJSONObject.Create;
          dataArr.AddElement(rowObj);

          addressText := Trim(
            ApiDB.uqComplaintWarningsSTRNUMBER.AsString + ' ' +
            ApiDB.uqComplaintWarningsSTRHNUMBER.AsString + ' ' +
            ApiDB.uqComplaintWarningsSTRPREFIX.AsString + ' ' +
            ApiDB.uqComplaintWarningsSTRNAME.AsString + ' ' +
            ApiDB.uqComplaintWarningsSTRSUFFIX.AsString
          );

          apartmentText := Trim(ApiDB.uqComplaintWarningsAPARTMENT.AsString);
          if apartmentText <> '' then
            addressText := Trim(addressText + ' ' + apartmentText);

          cityText := Trim(ApiDB.uqComplaintWarningsCITY.AsString);
          if cityText <> '' then
          begin
            if addressText <> '' then
              addressText := addressText + ', ' + cityText
            else
              addressText := cityText;
          end;

          rowObj.AddPair('CodeDesc', ApiDB.uqComplaintWarningsCODE_DESC.AsString);
          rowObj.AddPair('Address', addressText);
          rowObj.AddPair('Notes', ApiDB.uqComplaintWarningsNOTES.AsString);

          Inc(returnedCount);
          Next;
        end;
      finally
        Close;
      end;
    end;

    Result.AddPair('count', TJSONNumber.Create(returnedCount));
    Result.AddPair('returned', TJSONNumber.Create(returnedCount));

    Logger.Log(3, '---TApiService.GetComplaintWarnings End (returned=' + IntToStr(returnedCount) + ')');
  except
    on E: EXDataHttpException do
    begin
      Logger.Log(2, '---TApiService.GetComplaintWarnings http error: ' + E.Message);
      raise;
    end;
    on E: Exception do
    begin
      Logger.Log(2, '---TApiService.GetComplaintWarnings error: ' + E.ClassName + ': ' + E.Message);
      raise EXDataHttpException.Create(500, 'Failed to load complaint warnings');
    end;
  end;
end;

function TApiService.GetUnitLogs(const UnitId: string): TJSONObject;
var
  dataArr: TJSONArray;
  rowObj: TJSONObject;
  returnedCount: Integer;
  ts: string;
  complaintText: string;
begin
  Logger.Log(4, '---TApiService.GetUnitLogs initiated: ' + UnitId);

  Result := TJSONObject.Create;
  TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);

  dataArr := TJSONArray.Create;
  Result.AddPair('data', dataArr);

  returnedCount := 0;

  try
    with ApiDB.uqUnitLogs do
    begin
      ParamByName('UNITID').AsString := UnitId;
      Open;
      try
        while not Eof do
        begin
          rowObj := TJSONObject.Create;
          dataArr.AddElement(rowObj);

          ts := '';
          if not ApiDB.uqUnitLogsLOG_TIME.IsNull then
            ts := FormatDateTime('yyyy-mm-dd' + '  ' + 'HH:nn:ss', ApiDB.uqUnitLogsLOG_TIME.AsDateTime);

          complaintText := Trim(ApiDB.uqUnitLogsCOMPLAINT_NUM.AsString);

          rowObj.AddPair('LogTime', ts);
          rowObj.AddPair('Complaint', complaintText);
          rowObj.AddPair('Log', ApiDB.uqUnitLogsLOG_TEXT.AsString);

          Inc(returnedCount);
          Next;
        end;
      finally
        Close;
      end;
    end;

    Result.AddPair('count', TJSONNumber.Create(returnedCount));
    Result.AddPair('returned', TJSONNumber.Create(returnedCount));

    Logger.Log(4, '---TApiService.GetUnitLogs End (returned=' + IntToStr(returnedCount) + ')');
  except
    on E: EXDataHttpException do
    begin
      Logger.Log(2, '---TApiService.GetUnitLogs http error: ' + E.Message);
      raise;
    end;
    on E: Exception do
    begin
      Logger.Log(2, '---TApiService.GetUnitLogs error: ' + E.ClassName + ': ' + E.Message);
      raise EXDataHttpException.Create(500, 'Failed to load unit logs');
    end;
  end;
end;



function TApiService.GetUnitDetails(const UnitId: string): TJSONObject;
var
  obj: TJSONObject;
  updateTimeText: string;
  unitStatus: string;
begin
  Logger.Log(4, '---TApiService.GetUnitDetails initiated: ' + UnitId);

  Result := TJSONObject.Create;
  TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);

  try
    with ApiDB.uqUnitDetails do
    begin
      Close;
      ParamByName('UNITID').AsString := UnitId;
      Open;
      try
        if Eof then
          raise EXDataHttpException.Create(404, 'Unit not found');

        obj := TJSONObject.Create;
        Result.AddPair('data', obj);

        obj.AddPair('UnitId', ApiDB.uqUnitDetailsUNITID.AsString);
        obj.AddPair('UnitName', ApiDB.uqUnitDetailsUNITNAME.AsString);
        obj.AddPair('CarNumberDesc', ApiDB.uqUnitDetailsCARNUMBER_DESC.AsString);
        obj.AddPair('District', ApiDB.uqUnitDetailsDISTRICT_DESC.AsString);
        obj.AddPair('Location', ApiDB.uqUnitDetailsLOCATION.AsString);

        unitStatus := Trim(ApiDB.uqUnitDetailsUNIT_STATUS_DESC.AsString);
        if unitStatus = '' then
          unitStatus := 'Available';
        obj.AddPair('Status', unitStatus);

        obj.AddPair('Officer1Lname', ApiDB.uqUnitDetailsOFFICER1_LNAME.AsString);
        obj.AddPair('Officer1Fname', ApiDB.uqUnitDetailsOFFICER1_FNAME.AsString);
        obj.AddPair('Officer1Empnum', ApiDB.uqUnitDetailsOFFICER1_EMPNUM.AsString);

        obj.AddPair('Officer2Lname', ApiDB.uqUnitDetailsOFFICER2_LNAME.AsString);
        obj.AddPair('Officer2Fname', ApiDB.uqUnitDetailsOFFICER2_FNAME.AsString);
        obj.AddPair('Officer2Empnum', ApiDB.uqUnitDetailsOFFICER2_EMPNUM.AsString);

        updateTimeText := '';
        if not ApiDB.uqUnitDetailsUPDATE_TIME.IsNull then
          updateTimeText := FormatDateTime('yyyy-mm-dd hh:nn:ss', ApiDB.uqUnitDetailsUPDATE_TIME.AsDateTime);
        obj.AddPair('UpdateTime', updateTimeText);

      finally
        Close;
      end;
    end;
  except
    on E: EXDataHttpException do
    begin
      Logger.Log(2, '---TApiService.GetUnitDetails http error: ' + E.ClassName + ': ' + E.Message);
      raise;
    end;
    on E: Exception do
    begin
      Logger.Log(2, '---TApiService.GetUnitDetails error: ' + E.ClassName + ': ' + E.Message);
      raise EXDataHttpException.Create(500, 'Failed to load unit details');
    end;
  end;

  Logger.Log(4, '---TApiService.GetUnitDetails End');
end;


// ---------------------------------------------------------------------------
// Device Management
// ---------------------------------------------------------------------------

procedure TApiService.RequireAdmin;
var
  ctx: THttpServerContext;
  authHeader, b64payload, payload: string;
  parts: TArray<string>;
  padLen: Integer;
  payloadObj: TJSONObject;
  adminVal: TJSONValue;
  isAdmin: Boolean;
begin
  isAdmin := False;
  try
    // The JWT middleware has already validated the token signature.
    // Decode the payload to read the user_admin claim.
    // THttpServerContext.Current is the Sparkle thread-local request context.
    ctx := THttpServerContext.Current;
    if ctx <> nil then
    begin
      authHeader := ctx.Request.Headers.Get('Authorization');
      if authHeader.StartsWith('Bearer ') then
      begin
        parts := authHeader.Substring(7).Split(['.']);
        if Length(parts) >= 2 then
        begin
          // JWT uses URL-safe base64 (no padding) — convert before decoding
          b64payload := parts[1].Replace('-', '+').Replace('_', '/');
          padLen := (4 - Length(b64payload) mod 4) mod 4;
          b64payload := b64payload + StringOfChar('=', padLen);
          payload := TEncoding.UTF8.GetString(TNetEncoding.Base64.DecodeStringToBytes(b64payload));
          payloadObj := TJSONObject.ParseJSONValue(payload) as TJSONObject;
          if Assigned(payloadObj) then
          try
            adminVal := payloadObj.GetValue('user_admin');
            isAdmin := Assigned(adminVal) and (adminVal is TJSONBool) and
                       TJSONBool(adminVal).AsBoolean;
          finally
            payloadObj.Free;
          end;
        end;
      end;
    end;
  except
    isAdmin := False;
  end;

  if not isAdmin then
    raise EXDataHttpException.Create(403, 'Admin access required');
end;

function TApiService.OpenLemsConnection: TUniConnection;
begin
  Result := TUniConnection.Create(nil);
  Result.ProviderName := 'PostgreSQL';
  Result.Server   := IniEntries.DatabaseServer;
  Result.Port     := IniEntries.DatabasePort;
  Result.Database := IniEntries.DatabaseName;
  Result.Username := IniEntries.DatabaseUsername;
  Result.Password := IniEntries.DatabasePassword;
  Result.LoginPrompt := False;
  Result.Connect;
end;

function TApiService.GetDeviceList: TDeviceList;
var
  conn: TUniConnection;
  q: TUniQuery;
  item: TDeviceItem;
begin
  RequireAdmin;

  Logger.Log(2, 'TApiService.GetDeviceList - call');

  Result := TDeviceList.Create;
  TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);
  Result.data := TList<TDeviceItem>.Create;
  TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result.data);

  conn := OpenLemsConnection;
  try
    q := TUniQuery.Create(nil);
    try
      q.Connection := conn;
      q.SQL.Text :=
        'SELECT id, credential_id, device_name, user_agent, ' +
        '  registered_at, revoked_at, revoked_by ' +
        'FROM lems.device_registrations ' +
        'ORDER BY registered_at DESC';
      q.Open;
      try
        while not q.Eof do
        begin
          item := TDeviceItem.Create;
          TXDataOperationContext.Current.Handler.ManagedObjects.Add(item);

          item.id            := q.FieldByName('id').AsInteger;
          item.credential_id := q.FieldByName('credential_id').AsString;
          item.device_name   := q.FieldByName('device_name').AsString;
          item.user_agent    := q.FieldByName('user_agent').AsString;
          item.registered_at := q.FieldByName('registered_at').AsString;
          if q.FieldByName('revoked_at').IsNull then
            item.revoked_at := ''
          else
            item.revoked_at := q.FieldByName('revoked_at').AsString;
          item.revoked_by := q.FieldByName('revoked_by').AsString;

          Result.data.Add(item);
          q.Next;
        end;
      finally
        q.Close;
      end;
    finally
      q.Free;
    end;
  finally
    conn.Free;
  end;

  Result.count    := Result.data.Count;
  Result.returned := Result.data.Count;
  Logger.Log(2, 'TApiService.GetDeviceList - returned ' + IntToStr(Result.count));
end;

function TApiService.RevokeDevice(const CredentialId: string): TJSONObject;
var
  conn: TUniConnection;
  q: TUniQuery;
  ctx: THttpServerContext;
  revokedBy, authHeader, b64p, payload: string;
  parts: TArray<string>;
  padLen: Integer;
  payloadObj: TJSONObject;
begin
  RequireAdmin;

  Result := TJSONObject.Create;
  TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);

  if Trim(CredentialId) = '' then
  begin
    Result.AddPair('status', 'error');
    Result.AddPair('message', 'CredentialId is required.');
    Exit;
  end;

  // Extract revoking admin's username from JWT payload for audit trail
  revokedBy := 'admin';
  try
    ctx := THttpServerContext.Current;
    if ctx <> nil then
    begin
      authHeader := ctx.Request.Headers.Get('Authorization');
      if authHeader.StartsWith('Bearer ') then
      begin
        parts := authHeader.Substring(7).Split(['.']);
        if Length(parts) >= 2 then
        begin
          b64p := parts[1].Replace('-', '+').Replace('_', '/');
          padLen := (4 - Length(b64p) mod 4) mod 4;
          b64p := b64p + StringOfChar('=', padLen);
          payload := TEncoding.UTF8.GetString(TNetEncoding.Base64.DecodeStringToBytes(b64p));
          payloadObj := TJSONObject.ParseJSONValue(payload) as TJSONObject;
          if Assigned(payloadObj) then
          try
            revokedBy := payloadObj.GetValue<string>('user_name', 'admin');
          finally
            payloadObj.Free;
          end;
        end;
      end;
    end;
  except
    revokedBy := 'admin';
  end;

  Logger.Log(2, Format('TApiService.RevokeDevice - credId: %s by: %s', [Copy(CredentialId, 1, 20), revokedBy]));

  conn := OpenLemsConnection;
  try
    q := TUniQuery.Create(nil);
    try
      q.Connection := conn;
      q.SQL.Text :=
        'UPDATE lems.device_registrations ' +
        'SET revoked_at = NOW(), revoked_by = :REVOKED_BY ' +
        'WHERE credential_id = :CID AND revoked_at IS NULL';
      q.ParamByName('REVOKED_BY').AsString := revokedBy;
      q.ParamByName('CID').AsString        := Trim(CredentialId);
      q.ExecSQL;

      if q.RowsAffected > 0 then
      begin
        Logger.Log(2, 'TApiService.RevokeDevice - revoked credential: ' + Copy(CredentialId, 1, 20));
        Result.AddPair('status', 'ok');
        Result.AddPair('message', 'Device access revoked.');
      end
      else
      begin
        Logger.Log(2, 'TApiService.RevokeDevice - not found or already revoked: ' + Copy(CredentialId, 1, 20));
        Result.AddPair('status', 'error');
        Result.AddPair('message', 'Device not found or already revoked.');
      end;
    finally
      q.Free;
    end;
  finally
    conn.Free;
  end;
end;


initialization
  RegisterServiceType(TApiService);

end.

